- 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
113 lines
3.4 KiB
Python
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
|