Files
konstruct/packages/shared/shared/email.py
Adolfo Delorenzo 9654982433 feat(07-01): localized emails, locale-aware templates API, language preference endpoint
- email.py: send_invite_email() adds language param (en/es/pt), sends localized subject+body
- templates.py: list_templates()/get_template() accept ?locale= param, merge translations on response
- portal.py: PATCH /api/portal/users/me/language endpoint persists language preference
- portal.py: /api/portal/auth/verify response includes user.language field
- portal.py: AuthVerifyResponse adds language field (default 'en')
- test_portal_auth.py: fix _make_user mock to set language='en' (auto-fix Rule 1)
- test_language_preference.py: 4 integration tests for language preference endpoint
- test_templates_i18n.py: 5 integration tests for locale-aware templates (all passing)
2026-03-25 16:27:14 -06:00

226 lines
6.9 KiB
Python

"""
SMTP email utility for Konstruct invitation emails.
Sync function designed to be called from Celery tasks (sync def, asyncio.run() per
Phase 1 architectural constraint). Uses stdlib smtplib — no additional dependencies.
If SMTP is not configured (empty smtp_host), logs a warning and returns without
sending. This allows the invitation flow to function in dev environments without
a mail server.
Supports localized invitation emails in English (en), Spanish (es), and Portuguese (pt).
Falls back to English for unsupported locales.
"""
from __future__ import annotations
import logging
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from shared.config import settings
logger = logging.getLogger(__name__)
_SUPPORTED_LANGUAGES = {"en", "es", "pt"}
# ---------------------------------------------------------------------------
# Localized email copy
# ---------------------------------------------------------------------------
_SUBJECTS: dict[str, str] = {
"en": "You've been invited to join {tenant_name} on Konstruct",
"es": "Has sido invitado a unirte a {tenant_name} en Konstruct",
"pt": "Voce foi convidado para se juntar a {tenant_name} no Konstruct",
}
_TEXT_BODIES: dict[str, str] = {
"en": """\
Hi {invitee_name},
You've been invited to join {tenant_name} on Konstruct, the AI workforce platform.
Click the link below to accept your invitation and set up your account:
{invite_url}
This invitation expires in 48 hours.
If you did not expect this invitation, you can safely ignore this email.
— The Konstruct Team
""",
"es": """\
Hola {invitee_name},
Has sido invitado a unirte a {tenant_name} en Konstruct, la plataforma de empleados de IA.
Haz clic en el enlace a continuacion para aceptar tu invitacion y configurar tu cuenta:
{invite_url}
Esta invitacion vence en 48 horas.
Si no esperabas esta invitacion, puedes ignorar este correo de forma segura.
— El Equipo de Konstruct
""",
"pt": """\
Ola {invitee_name},
Voce foi convidado para se juntar a {tenant_name} no Konstruct, a plataforma de funcionarios de IA.
Clique no link abaixo para aceitar o seu convite e configurar sua conta:
{invite_url}
Este convite expira em 48 horas.
Se voce nao estava esperando este convite, pode ignorar este e-mail com seguranca.
— O Time Konstruct
""",
}
_HTML_BODIES: dict[str, str] = {
"en": """\
<html>
<body style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
<h2>You've been invited to join {tenant_name}</h2>
<p>Hi {invitee_name},</p>
<p>
You've been invited to join <strong>{tenant_name}</strong> on
<strong>Konstruct</strong>, the AI workforce platform.
</p>
<p>
<a href="{invite_url}"
style="display: inline-block; padding: 12px 24px; background: #2563eb;
color: white; text-decoration: none; border-radius: 6px;">
Accept Invitation
</a>
</p>
<p style="color: #6b7280; font-size: 0.9em;">
This invitation expires in 48 hours. If you did not expect this email,
you can safely ignore it.
</p>
</body>
</html>""",
"es": """\
<html>
<body style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
<h2>Has sido invitado a unirte a {tenant_name}</h2>
<p>Hola {invitee_name},</p>
<p>
Has sido invitado a unirte a <strong>{tenant_name}</strong> en
<strong>Konstruct</strong>, la plataforma de empleados de IA.
</p>
<p>
<a href="{invite_url}"
style="display: inline-block; padding: 12px 24px; background: #2563eb;
color: white; text-decoration: none; border-radius: 6px;">
Aceptar Invitacion
</a>
</p>
<p style="color: #6b7280; font-size: 0.9em;">
Esta invitacion vence en 48 horas. Si no esperabas este correo,
puedes ignorarlo de forma segura.
</p>
</body>
</html>""",
"pt": """\
<html>
<body style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
<h2>Voce foi convidado para se juntar a {tenant_name}</h2>
<p>Ola {invitee_name},</p>
<p>
Voce foi convidado para se juntar a <strong>{tenant_name}</strong> no
<strong>Konstruct</strong>, a plataforma de funcionarios de IA.
</p>
<p>
<a href="{invite_url}"
style="display: inline-block; padding: 12px 24px; background: #2563eb;
color: white; text-decoration: none; border-radius: 6px;">
Aceitar Convite
</a>
</p>
<p style="color: #6b7280; font-size: 0.9em;">
Este convite expira em 48 horas. Se voce nao estava esperando este e-mail,
pode ignora-lo com seguranca.
</p>
</body>
</html>""",
}
def send_invite_email(
to_email: str,
invitee_name: str,
tenant_name: str,
invite_url: str,
language: str = "en",
) -> None:
"""
Send an invitation email via SMTP, optionally in the inviter's language.
Args:
to_email: Recipient email address.
invitee_name: Recipient's display name (for personalization).
tenant_name: Name of the tenant they're being invited to.
invite_url: The full invitation acceptance URL (includes raw token).
language: Language for the email body. Supported: 'en', 'es', 'pt'.
Falls back to 'en' for unsupported locales.
Note:
Called from a Celery task (sync). Silently skips if smtp_host is empty.
"""
if not settings.smtp_host:
logger.warning(
"SMTP not configured (smtp_host is empty) — skipping invite email to %s",
to_email,
)
return
# Normalize language — fall back to English for unsupported locales
lang = language if language in _SUPPORTED_LANGUAGES else "en"
subject = _SUBJECTS[lang].format(tenant_name=tenant_name)
text_body = _TEXT_BODIES[lang].format(
invitee_name=invitee_name,
tenant_name=tenant_name,
invite_url=invite_url,
)
html_body = _HTML_BODIES[lang].format(
invitee_name=invitee_name,
tenant_name=tenant_name,
invite_url=invite_url,
)
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = settings.smtp_from_email
msg["To"] = to_email
msg.attach(MIMEText(text_body, "plain"))
msg.attach(MIMEText(html_body, "html"))
try:
with smtplib.SMTP(settings.smtp_host, settings.smtp_port) as server:
server.ehlo()
if settings.smtp_port == 587:
server.starttls()
if settings.smtp_username and settings.smtp_password:
server.login(settings.smtp_username, settings.smtp_password)
server.sendmail(settings.smtp_from_email, [to_email], msg.as_string())
logger.info(
"Invite email sent to %s for tenant %s (language=%s)", to_email, tenant_name, lang
)
except Exception:
logger.exception(
"Failed to send invite email to %s (smtp_host=%s)",
to_email,
settings.smtp_host,
)
# Re-raise to allow Celery to retry if configured
raise