Files
konstruct/packages/shared/shared/email.py
Adolfo Delorenzo d59f85cd87 feat(04-rbac-01): RBAC guards + invite token + email + invitation API
- rbac.py: PortalCaller dataclass + get_portal_caller dependency (header-based)
- rbac.py: require_platform_admin (403 for non-platform_admin)
- rbac.py: require_tenant_admin (platform_admin bypasses; customer_admin
  checks UserTenantRole; operator always rejected)
- rbac.py: require_tenant_member (platform_admin bypasses; all roles
  checked against UserTenantRole)
- invite_token.py: generate_invite_token (HMAC-SHA256, base64url, 48h TTL)
- invite_token.py: validate_invite_token (timing-safe compare_digest, TTL check)
- invite_token.py: token_to_hash (SHA-256 for DB storage)
- email.py: send_invite_email (sync smtplib, skips if smtp_host empty)
- invitations.py: POST /api/portal/invitations (create, requires tenant admin)
- invitations.py: POST /api/portal/invitations/accept (accept invitation)
- invitations.py: POST /api/portal/invitations/{id}/resend (regenerate token)
- invitations.py: GET /api/portal/invitations (list pending)
- portal.py: AuthVerifyResponse now returns role+tenant_ids+active_tenant_id
- portal.py: auth/register gated behind require_platform_admin
- tasks.py: send_invite_email_task Celery task (fire-and-forget)
- gateway/main.py: invitations_router mounted
2026-03-24 13:52:45 -06:00

113 lines
3.4 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.
"""
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__)
def send_invite_email(
to_email: str,
invitee_name: str,
tenant_name: str,
invite_url: str,
) -> None:
"""
Send an invitation email via SMTP.
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).
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
subject = f"You've been invited to join {tenant_name} on Konstruct"
text_body = f"""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
"""
html_body = f"""<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>"""
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", to_email, tenant_name)
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