- 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)
226 lines
6.9 KiB
Python
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
|