email template
This commit is contained in:
parent
ebbb582e99
commit
2451045693
15 changed files with 447 additions and 156 deletions
|
|
@ -17,77 +17,43 @@ 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,
|
||||
invite_url: str,
|
||||
expires_hours: int = 48,
|
||||
) -> 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"{manager} invited you to join {organization}"
|
||||
|
||||
why_received = (
|
||||
f"You are receiving this because {manager} invited you to a work email "
|
||||
f"account at {organization} on {BRAND_NAME}. It was sent to the personal "
|
||||
f"email address your administrator provided so you can complete your "
|
||||
f"first sign-in."
|
||||
)
|
||||
|
||||
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"{why_received}\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"{manager} invited you to join {organization}.\n\n"
|
||||
f"Your work email:\n"
|
||||
f"{member_email}\n\n"
|
||||
f"Set up your account:\n"
|
||||
f"{invite_url}\n\n"
|
||||
f"This invitation expires in {expires_hours} hours.\n\n"
|
||||
f"If you weren't expecting this invitation,\n"
|
||||
f"you can safely ignore this email.\n\n"
|
||||
f"—\n"
|
||||
f"{BRAND_NAME}\n"
|
||||
f"{login_url.rsplit('/login', 1)[0] or login_url}\n"
|
||||
f"This is a one-time account notification from {BRAND_NAME} on behalf of "
|
||||
f"{organization}. You cannot unsubscribe from required account emails."
|
||||
f"This invitation was sent because {manager} created an account "
|
||||
f"for you at {organization}."
|
||||
)
|
||||
|
||||
preheader = (
|
||||
f"{manager} invited you to join {organization}. "
|
||||
f"Use the sign-in details below to access your work inbox."
|
||||
f"Set up your work email account."
|
||||
)
|
||||
|
||||
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_invite = _escape(invite_url)
|
||||
safe_preheader = _escape(preheader)
|
||||
safe_security = _escape(security_note)
|
||||
safe_unexpected = _escape(unexpected_contact)
|
||||
safe_why_received = _escape(why_received)
|
||||
|
||||
body_html = f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
|
@ -142,63 +108,43 @@ def build_employee_invitation_email(
|
|||
</tr>
|
||||
<tr>
|
||||
<td style="padding:28px 32px 8px;">
|
||||
<p style="margin:0 0 16px;font-size:16px;line-height:1.65;color:#080821;">
|
||||
<p style="margin:0 0 24px;font-size:16px;line-height:1.65;color:#080821;">
|
||||
Hello {safe_first},
|
||||
</p>
|
||||
<p style="margin:0 0 24px;font-size:15px;line-height:1.7;color:#67677a;">
|
||||
{safe_why_received}
|
||||
</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>
|
||||
<p style="margin:0 0 8px;font-size:12px;font-weight:700;letter-spacing:0.4px;
|
||||
text-transform:uppercase;color:#c85e49;">Your work email</p>
|
||||
<p style="margin:0;font-size:15px;font-weight:600;color:#080821;">{safe_email}</p>
|
||||
</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;
|
||||
<a href="{safe_invite}" 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}
|
||||
Set up your account
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin:0 0 16px;font-size:14px;line-height:1.65;color:#67677a;">
|
||||
{safe_security}
|
||||
This invitation expires in {expires_hours} hours.
|
||||
</p>
|
||||
<p style="margin:0;font-size:14px;line-height:1.65;color:#67677a;">
|
||||
{safe_unexpected}
|
||||
If you weren't expecting this invitation,<br />
|
||||
you can safely ignore this email.
|
||||
</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;">
|
||||
One-time account notification from {BRAND_NAME} on behalf of {safe_org}.
|
||||
{" Questions? Reply to " + safe_manager_email + "." if manager_address else ""}
|
||||
This invitation was sent because {safe_manager} created an account
|
||||
for you at {safe_org}.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
|||
79
backend/app/services/member_invitations.py
Normal file
79
backend/app/services/member_invitations.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
"""Single-use invitation tokens for employee onboarding."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models import Account, MemberInvitation
|
||||
|
||||
INVITE_EXPIRY_HOURS = 48
|
||||
INVITE_FROM_LOCAL = "invites"
|
||||
|
||||
|
||||
def hash_invite_token(raw_token: str) -> str:
|
||||
return hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def generate_invite_token() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
async def invalidate_pending_invitations(
|
||||
session: AsyncSession,
|
||||
account_id,
|
||||
) -> None:
|
||||
invitations = await session.scalars(
|
||||
select(MemberInvitation).where(
|
||||
MemberInvitation.account_id == account_id,
|
||||
MemberInvitation.used_at.is_(None),
|
||||
)
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
for invitation in invitations.all():
|
||||
invitation.used_at = now
|
||||
|
||||
|
||||
async def create_member_invitation(
|
||||
session: AsyncSession,
|
||||
member: Account,
|
||||
) -> str:
|
||||
await invalidate_pending_invitations(session, member.id)
|
||||
raw_token = generate_invite_token()
|
||||
invitation = MemberInvitation(
|
||||
account_id=member.id,
|
||||
token_hash=hash_invite_token(raw_token),
|
||||
expires_at=datetime.now(timezone.utc)
|
||||
+ timedelta(hours=INVITE_EXPIRY_HOURS),
|
||||
)
|
||||
session.add(invitation)
|
||||
await session.flush()
|
||||
return raw_token
|
||||
|
||||
|
||||
async def get_valid_invitation(
|
||||
session: AsyncSession,
|
||||
raw_token: str,
|
||||
) -> tuple[MemberInvitation, Account]:
|
||||
token_hash = hash_invite_token(raw_token.strip())
|
||||
invitation = await session.scalar(
|
||||
select(MemberInvitation)
|
||||
.options(selectinload(MemberInvitation.account))
|
||||
.where(MemberInvitation.token_hash == token_hash)
|
||||
)
|
||||
if invitation is None:
|
||||
raise ValueError("This invitation link is invalid.")
|
||||
if invitation.used_at is not None:
|
||||
raise ValueError("This invitation has already been used.")
|
||||
now = datetime.now(timezone.utc)
|
||||
if invitation.expires_at <= now:
|
||||
raise ValueError("This invitation has expired.")
|
||||
account = invitation.account
|
||||
if account is None or account.member_status != "invited":
|
||||
raise ValueError("This invitation is no longer valid.")
|
||||
return invitation, account
|
||||
|
|
@ -9,6 +9,11 @@ from app.models import Account
|
|||
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.member_invitations import (
|
||||
INVITE_EXPIRY_HOURS,
|
||||
INVITE_FROM_LOCAL,
|
||||
create_member_invitation,
|
||||
)
|
||||
|
||||
|
||||
async def send_employee_invitation(
|
||||
|
|
@ -16,8 +21,6 @@ async def send_employee_invitation(
|
|||
*,
|
||||
member: Account,
|
||||
manager: Account,
|
||||
initial_password: str,
|
||||
force_password_change: bool,
|
||||
) -> None:
|
||||
if not member.invitation_email:
|
||||
raise ValueError("Invitation email is required.")
|
||||
|
|
@ -27,7 +30,8 @@ async def send_employee_invitation(
|
|||
if platform_domain is None:
|
||||
raise ValueError("Platform mail domain is not configured.")
|
||||
|
||||
login_url = f"https://{settings.app_public_host}/login"
|
||||
raw_token = await create_member_invitation(session, member)
|
||||
invite_url = f"https://{settings.app_public_host}/invite/{raw_token}"
|
||||
manager_name = f"{manager.first_name} {manager.last_name}".strip()
|
||||
company = member.company_domain or settings.platform_domain
|
||||
|
||||
|
|
@ -35,14 +39,12 @@ async def send_employee_invitation(
|
|||
member_first_name=member.first_name,
|
||||
member_email=member.email,
|
||||
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,
|
||||
invite_url=invite_url,
|
||||
expires_hours=INVITE_EXPIRY_HOURS,
|
||||
)
|
||||
|
||||
sender = f"{BRAND_NAME} <noreply@{settings.platform_domain}>"
|
||||
sender = f"{BRAND_NAME} <{INVITE_FROM_LOCAL}@{settings.platform_domain}>"
|
||||
await deliver_email(
|
||||
sender=sender,
|
||||
recipients=[member.invitation_email],
|
||||
|
|
|
|||
|
|
@ -86,10 +86,16 @@ async def import_org_members(
|
|||
row_errors.append("Duplicate work email in this file.")
|
||||
seen_emails.add(work_email)
|
||||
|
||||
password = row.password.strip()
|
||||
password_issue = _password_error(password)
|
||||
if password_issue:
|
||||
row_errors.append(password_issue)
|
||||
password = (row.password or "").strip()
|
||||
if payload.send_invitation:
|
||||
if password:
|
||||
row_errors.append(
|
||||
"Password is not needed when sending invitation emails."
|
||||
)
|
||||
else:
|
||||
password_issue = _password_error(password)
|
||||
if password_issue:
|
||||
row_errors.append(password_issue)
|
||||
|
||||
invitation_email = (
|
||||
str(row.invitation_email).lower().strip()
|
||||
|
|
@ -132,7 +138,7 @@ async def import_org_members(
|
|||
org_role=payload.org_role,
|
||||
send_invitation=payload.send_invitation,
|
||||
force_password_change=payload.force_password_change,
|
||||
password=password,
|
||||
password=password or None,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -154,10 +154,16 @@ async def create_org_member(
|
|||
org_role=payload.org_role,
|
||||
member_status=member_status,
|
||||
invitation_email=invitation_email,
|
||||
must_change_password=payload.force_password_change,
|
||||
must_change_password=(
|
||||
payload.force_password_change if not payload.send_invitation else False
|
||||
),
|
||||
section_id=section.id if section else None,
|
||||
created_by_id=manager.id,
|
||||
password_hash=password_hasher.hash(payload.password),
|
||||
password_hash=password_hasher.hash(
|
||||
payload.password
|
||||
if payload.password
|
||||
else secrets.token_urlsafe(48)
|
||||
),
|
||||
)
|
||||
session.add(member)
|
||||
await session.flush()
|
||||
|
|
@ -171,8 +177,6 @@ async def create_org_member(
|
|||
session,
|
||||
member=member,
|
||||
manager=manager,
|
||||
initial_password=payload.password,
|
||||
force_password_change=payload.force_password_change,
|
||||
)
|
||||
invitation_sent = True
|
||||
except Exception as error:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue