agentic email invite html
This commit is contained in:
parent
42cd937f0a
commit
65bb73f30b
6 changed files with 273 additions and 32 deletions
219
backend/app/services/email_templates.py
Normal file
219
backend/app/services/email_templates.py
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
"""HTML and plain-text templates for transactional email."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
|
|
||||||
|
BRAND_NAME = "aGentic Email"
|
||||||
|
BRAND_HANDLE = "@gentic.email"
|
||||||
|
|
||||||
|
|
||||||
|
def _escape(value: str) -> str:
|
||||||
|
return html.escape(value, quote=True)
|
||||||
|
|
||||||
|
|
||||||
|
def build_employee_invitation_email(
|
||||||
|
*,
|
||||||
|
member_first_name: str,
|
||||||
|
member_email: str,
|
||||||
|
manager_name: str,
|
||||||
|
manager_email: str,
|
||||||
|
company_domain: str,
|
||||||
|
login_url: str,
|
||||||
|
initial_password: str,
|
||||||
|
force_password_change: bool,
|
||||||
|
) -> tuple[str, str, str]:
|
||||||
|
first_name = member_first_name.strip() or "there"
|
||||||
|
organization = company_domain.strip()
|
||||||
|
manager = manager_name.strip() or "Your administrator"
|
||||||
|
manager_address = manager_email.strip()
|
||||||
|
|
||||||
|
security_note = (
|
||||||
|
"For your security, you will be asked to create a new password "
|
||||||
|
"immediately after your first sign-in."
|
||||||
|
if force_password_change
|
||||||
|
else (
|
||||||
|
"We recommend updating your password after your first sign-in "
|
||||||
|
"from your account settings."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
subject = (
|
||||||
|
f"Account invitation: {manager} added you to {organization} on {BRAND_NAME}"
|
||||||
|
)
|
||||||
|
|
||||||
|
unexpected_contact = (
|
||||||
|
f"If you were not expecting this invitation, please contact "
|
||||||
|
f"{manager} at {manager_address}."
|
||||||
|
if manager_address
|
||||||
|
else "If you were not expecting this invitation, please contact your administrator."
|
||||||
|
)
|
||||||
|
|
||||||
|
body_text = (
|
||||||
|
f"Hello {first_name},\n\n"
|
||||||
|
f"{manager} has created a work email account for you on {BRAND_NAME}, "
|
||||||
|
f"the email platform used by {organization}.\n\n"
|
||||||
|
f"This automated notification was sent to the personal email address "
|
||||||
|
f"your administrator provided. It contains the sign-in details for "
|
||||||
|
f"your new organization account.\n\n"
|
||||||
|
f"Organization: {organization}\n"
|
||||||
|
f"Work email address: {member_email}\n"
|
||||||
|
f"Sign-in page: {login_url}\n\n"
|
||||||
|
f"Sign-in credentials\n"
|
||||||
|
f" Email: {member_email}\n"
|
||||||
|
f" Initial password: {initial_password}\n\n"
|
||||||
|
f"{security_note}\n\n"
|
||||||
|
f"{unexpected_contact}\n\n"
|
||||||
|
f"—\n"
|
||||||
|
f"{BRAND_NAME}\n"
|
||||||
|
f"{login_url.rsplit('/login', 1)[0] or login_url}\n"
|
||||||
|
f"This is an account notification sent on behalf of {organization}."
|
||||||
|
)
|
||||||
|
|
||||||
|
preheader = (
|
||||||
|
f"{manager} added you to {organization}. "
|
||||||
|
f"Your work email account is ready — review your sign-in details."
|
||||||
|
)
|
||||||
|
|
||||||
|
safe_first = _escape(first_name)
|
||||||
|
safe_manager = _escape(manager)
|
||||||
|
safe_org = _escape(organization)
|
||||||
|
safe_email = _escape(member_email)
|
||||||
|
safe_password = _escape(initial_password)
|
||||||
|
safe_login = _escape(login_url)
|
||||||
|
safe_manager_email = _escape(manager_address)
|
||||||
|
safe_preheader = _escape(preheader)
|
||||||
|
safe_security = _escape(security_note)
|
||||||
|
safe_unexpected = _escape(unexpected_contact)
|
||||||
|
|
||||||
|
body_html = f"""<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="color-scheme" content="light" />
|
||||||
|
<meta name="supported-color-schemes" content="light" />
|
||||||
|
<title>{_escape(subject)}</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;padding:0;background-color:#fffdfb;color:#080821;
|
||||||
|
font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
|
||||||
|
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">
|
||||||
|
{safe_preheader}
|
||||||
|
</div>
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0"
|
||||||
|
style="background-color:#fffdfb;padding:32px 16px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0"
|
||||||
|
style="max-width:600px;border-collapse:separate;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0 0 20px;">
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0">
|
||||||
|
<tr>
|
||||||
|
<td style="width:36px;height:34px;background-color:#080821;border-radius:10px 10px 12px 12px;
|
||||||
|
text-align:center;vertical-align:middle;">
|
||||||
|
<span style="display:inline-block;width:8px;height:8px;background-color:#ff6a52;
|
||||||
|
border-radius:50%;margin-top:8px;"></span>
|
||||||
|
</td>
|
||||||
|
<td style="padding-left:12px;font-size:18px;font-weight:700;color:#080821;letter-spacing:-0.3px;">
|
||||||
|
gentic<span style="color:#67677a;font-weight:500;">.email</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="background-color:#ffffff;border:1px solid #eee9e5;border-radius:20px;
|
||||||
|
box-shadow:0 18px 48px rgba(8,8,33,0.06);overflow:hidden;">
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:28px 32px 18px;background:linear-gradient(135deg,#fffdfb 0%,#fff6f2 100%);
|
||||||
|
border-bottom:1px solid #f3ece7;">
|
||||||
|
<p style="margin:0 0 10px;font-size:12px;font-weight:700;letter-spacing:1px;
|
||||||
|
text-transform:uppercase;color:#ff6a52;">Organization invitation</p>
|
||||||
|
<h1 style="margin:0;font-size:26px;line-height:1.25;font-weight:700;color:#080821;">
|
||||||
|
You have been added to<br />
|
||||||
|
<span style="color:#ff6a52;">{safe_org}</span>
|
||||||
|
</h1>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:28px 32px 8px;">
|
||||||
|
<p style="margin:0 0 16px;font-size:16px;line-height:1.65;color:#080821;">
|
||||||
|
Hello {safe_first},
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 16px;font-size:15px;line-height:1.7;color:#67677a;">
|
||||||
|
<strong style="color:#080821;">{safe_manager}</strong> has created a work email
|
||||||
|
account for you on <strong style="color:#080821;">{BRAND_NAME}</strong>,
|
||||||
|
the email platform used by <strong style="color:#080821;">{safe_org}</strong>
|
||||||
|
for business communication.
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 24px;font-size:15px;line-height:1.7;color:#67677a;">
|
||||||
|
This message was sent to the personal email address your administrator
|
||||||
|
provided. Use the details below to access your new organization inbox.
|
||||||
|
</p>
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0"
|
||||||
|
style="margin:0 0 24px;background-color:#fff3ef;border:1px solid #ffe1d8;border-radius:14px;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:18px 20px;">
|
||||||
|
<p style="margin:0 0 12px;font-size:12px;font-weight:700;letter-spacing:0.4px;
|
||||||
|
text-transform:uppercase;color:#c85e49;">Account details</p>
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0"
|
||||||
|
style="font-size:14px;line-height:1.6;color:#080821;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:4px 0;color:#67677a;width:130px;vertical-align:top;">Organization</td>
|
||||||
|
<td style="padding:4px 0;font-weight:600;">{safe_org}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:4px 0;color:#67677a;vertical-align:top;">Work email</td>
|
||||||
|
<td style="padding:4px 0;font-weight:600;">{safe_email}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:4px 0;color:#67677a;vertical-align:top;">Initial password</td>
|
||||||
|
<td style="padding:4px 0;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
|
||||||
|
font-weight:600;letter-spacing:0.3px;">{safe_password}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" style="margin:0 0 24px;">
|
||||||
|
<tr>
|
||||||
|
<td style="border-radius:12px;background-color:#080821;">
|
||||||
|
<a href="{safe_login}" style="display:inline-block;padding:14px 24px;font-size:15px;
|
||||||
|
font-weight:700;color:#ffffff;text-decoration:none;border-radius:12px;">
|
||||||
|
Sign in to {BRAND_NAME}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p style="margin:0 0 16px;font-size:14px;line-height:1.65;color:#67677a;">
|
||||||
|
{safe_security}
|
||||||
|
</p>
|
||||||
|
<p style="margin:0;font-size:14px;line-height:1.65;color:#67677a;">
|
||||||
|
{safe_unexpected}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:20px 32px 28px;border-top:1px solid #f0ebe7;background-color:#faf8f6;">
|
||||||
|
<p style="margin:0 0 6px;font-size:12px;line-height:1.6;color:#888795;">
|
||||||
|
{BRAND_NAME} · {BRAND_HANDLE}
|
||||||
|
</p>
|
||||||
|
<p style="margin:0;font-size:12px;line-height:1.6;color:#a8a4ae;">
|
||||||
|
This is an automated account notification sent on behalf of {safe_org}.
|
||||||
|
{" Reply to " + safe_manager_email + " if you need help." if manager_address else ""}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
return subject, body_text, body_html
|
||||||
|
|
@ -19,12 +19,15 @@ def build_message(
|
||||||
subject: str,
|
subject: str,
|
||||||
body_text: str,
|
body_text: str,
|
||||||
body_html: str | None,
|
body_html: str | None,
|
||||||
|
reply_to: str | None = None,
|
||||||
) -> EmailMessage:
|
) -> EmailMessage:
|
||||||
message = EmailMessage(policy=SMTP_POLICY)
|
message = EmailMessage(policy=SMTP_POLICY)
|
||||||
message["From"] = sender
|
message["From"] = sender
|
||||||
message["To"] = ", ".join(recipients)
|
message["To"] = ", ".join(recipients)
|
||||||
if cc:
|
if cc:
|
||||||
message["Cc"] = ", ".join(cc)
|
message["Cc"] = ", ".join(cc)
|
||||||
|
if reply_to:
|
||||||
|
message["Reply-To"] = reply_to
|
||||||
message["Subject"] = subject
|
message["Subject"] = subject
|
||||||
message["Date"] = email.utils.formatdate(localtime=True)
|
message["Date"] = email.utils.formatdate(localtime=True)
|
||||||
message["Message-ID"] = email.utils.make_msgid(domain=sender.split("@")[-1])
|
message["Message-ID"] = email.utils.make_msgid(domain=sender.split("@")[-1])
|
||||||
|
|
@ -90,6 +93,7 @@ async def deliver_email(
|
||||||
body_text: str,
|
body_text: str,
|
||||||
body_html: str | None,
|
body_html: str | None,
|
||||||
domain: MailDomain,
|
domain: MailDomain,
|
||||||
|
reply_to: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
message = build_message(
|
message = build_message(
|
||||||
sender=sender,
|
sender=sender,
|
||||||
|
|
@ -98,6 +102,7 @@ async def deliver_email(
|
||||||
subject=subject,
|
subject=subject,
|
||||||
body_text=body_text,
|
body_text=body_text,
|
||||||
body_html=body_html,
|
body_html=body_html,
|
||||||
|
reply_to=reply_to,
|
||||||
)
|
)
|
||||||
signed = sign_message(message, domain)
|
signed = sign_message(message, domain)
|
||||||
await send_via_smtp(
|
await send_via_smtp(
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,9 @@ from __future__ import annotations
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.models import Account, MailDomain
|
from app.models import Account
|
||||||
from app.services.domain_provisioning import get_domain_by_name
|
from app.services.domain_provisioning import get_domain_by_name
|
||||||
|
from app.services.email_templates import BRAND_NAME, build_employee_invitation_email
|
||||||
from app.services.mail_sender import deliver_email
|
from app.services.mail_sender import deliver_email
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -30,42 +31,26 @@ async def send_employee_invitation(
|
||||||
manager_name = f"{manager.first_name} {manager.last_name}".strip()
|
manager_name = f"{manager.first_name} {manager.last_name}".strip()
|
||||||
company = member.company_domain or settings.platform_domain
|
company = member.company_domain or settings.platform_domain
|
||||||
|
|
||||||
change_note = (
|
subject, body_text, body_html = build_employee_invitation_email(
|
||||||
"You will be asked to choose a new password immediately after signing in."
|
member_first_name=member.first_name,
|
||||||
if force_password_change
|
member_email=member.email,
|
||||||
else "You can change your password later from account settings."
|
manager_name=manager_name,
|
||||||
|
manager_email=manager.email,
|
||||||
|
company_domain=company,
|
||||||
|
login_url=login_url,
|
||||||
|
initial_password=initial_password,
|
||||||
|
force_password_change=force_password_change,
|
||||||
)
|
)
|
||||||
|
|
||||||
body_text = (
|
sender = f"{BRAND_NAME} <noreply@{settings.platform_domain}>"
|
||||||
f"Hi {member.first_name},\n\n"
|
|
||||||
f"{manager_name} invited you to AGMail for {company}.\n\n"
|
|
||||||
f"Sign in at: {login_url}\n"
|
|
||||||
f"Work email: {member.email}\n"
|
|
||||||
f"Temporary password: {initial_password}\n\n"
|
|
||||||
f"{change_note}\n\n"
|
|
||||||
"— AGMail"
|
|
||||||
)
|
|
||||||
body_html = (
|
|
||||||
f"<p>Hi {member.first_name},</p>"
|
|
||||||
f"<p><strong>{manager_name}</strong> invited you to AGMail for "
|
|
||||||
f"<strong>{company}</strong>.</p>"
|
|
||||||
f"<p><a href=\"{login_url}\">Sign in to AGMail</a></p>"
|
|
||||||
f"<ul>"
|
|
||||||
f"<li><strong>Work email:</strong> {member.email}</li>"
|
|
||||||
f"<li><strong>Temporary password:</strong> {initial_password}</li>"
|
|
||||||
f"</ul>"
|
|
||||||
f"<p>{change_note}</p>"
|
|
||||||
f"<p>— AGMail</p>"
|
|
||||||
)
|
|
||||||
|
|
||||||
sender = f"AGMail <noreply@{settings.platform_domain}>"
|
|
||||||
await deliver_email(
|
await deliver_email(
|
||||||
sender=sender,
|
sender=sender,
|
||||||
recipients=[member.invitation_email],
|
recipients=[member.invitation_email],
|
||||||
cc=[],
|
cc=[],
|
||||||
bcc=[],
|
bcc=[],
|
||||||
subject=f"You're invited to {company} on AGMail",
|
subject=subject,
|
||||||
body_text=body_text,
|
body_text=body_text,
|
||||||
body_html=body_html,
|
body_html=body_html,
|
||||||
domain=platform_domain,
|
domain=platform_domain,
|
||||||
|
reply_to=manager.email,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
32
backend/tests/test_email_templates.py
Normal file
32
backend/tests/test_email_templates.py
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
from app.services.email_templates import build_employee_invitation_email
|
||||||
|
|
||||||
|
|
||||||
|
def test_employee_invitation_uses_gentic_branding():
|
||||||
|
subject, body_text, body_html = build_employee_invitation_email(
|
||||||
|
member_first_name="Anurag",
|
||||||
|
member_email="anurag@road.rentals",
|
||||||
|
manager_name="Suyash Dixit",
|
||||||
|
manager_email="suyash@road.rentals",
|
||||||
|
company_domain="road.rentals",
|
||||||
|
login_url="https://gentic.email/login",
|
||||||
|
initial_password="Roadrentals123",
|
||||||
|
force_password_change=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "aGentic Email" in subject
|
||||||
|
assert subject == (
|
||||||
|
"Account invitation: Suyash Dixit added you to road.rentals on aGentic Email"
|
||||||
|
)
|
||||||
|
assert "AGMail" not in subject
|
||||||
|
assert "Suyash Dixit" in subject
|
||||||
|
assert "road.rentals" in subject
|
||||||
|
|
||||||
|
assert "Gentic Email" in body_text
|
||||||
|
assert "AGMail" not in body_text
|
||||||
|
assert "Suyash Dixit has created a work email account" in body_text
|
||||||
|
assert "anurag@road.rentals" in body_text
|
||||||
|
|
||||||
|
assert "gentic.email" in body_html
|
||||||
|
assert "Organization invitation" in body_html
|
||||||
|
assert "Sign in to aGentic Email" in body_html
|
||||||
|
assert "AGMail" not in body_html
|
||||||
|
|
@ -242,7 +242,7 @@ export default function AddEmployeeForm({
|
||||||
<SetupField
|
<SetupField
|
||||||
label={<RequiredLabel>Invitation email</RequiredLabel>}
|
label={<RequiredLabel>Invitation email</RequiredLabel>}
|
||||||
error={fieldErrors.invitation_email}
|
error={fieldErrors.invitation_email}
|
||||||
hint={`Personal email where AGMail sends the setup invite from @${platformDomain}.`}
|
hint={`Personal email where aGentic Email sends the setup invite from @${platformDomain}.`}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
|
|
@ -342,7 +342,7 @@ export default function AddEmployeeForm({
|
||||||
<label className="people-toggle-row">
|
<label className="people-toggle-row">
|
||||||
<span>
|
<span>
|
||||||
<strong>Require password change on first login</strong>
|
<strong>Require password change on first login</strong>
|
||||||
<small>Employee must choose a new password before using AGMail.</small>
|
<small>Employee must choose a new password before using aGentic Email.</small>
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ export default function ChangePasswordModal({ account, onComplete }) {
|
||||||
<h2 id="change-password-title">Choose a new password</h2>
|
<h2 id="change-password-title">Choose a new password</h2>
|
||||||
<p>
|
<p>
|
||||||
Hi {account.first_name}, your administrator requires you to set a new password
|
Hi {account.first_name}, your administrator requires you to set a new password
|
||||||
before using AGMail.
|
before using aGentic Email.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue