Add platform SMTP and firm wizard invite email

This commit is contained in:
A R R R Associates
2026-07-02 23:43:24 +05:30
parent b4b8dc97fd
commit b84090d8c9
12 changed files with 821 additions and 45 deletions
+32
View File
@@ -68,6 +68,38 @@ class EmailSetting(CommonBase):
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
class PlatformEmailSetting(CommonBase):
"""Platform-level SMTP settings controlled by System Admin.
Used before a tenant/firm is operational, for example Firm Creation
Wizard invites, platform password/reset notices and system security emails.
Firm/branch SMTP remains in EmailSetting and is not changed by this table.
"""
__tablename__ = "platform_email_settings"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
smtp_host: Mapped[str | None] = mapped_column(String(255), nullable=True)
smtp_port: Mapped[int | None] = mapped_column(Integer, nullable=True)
smtp_username: Mapped[str | None] = mapped_column(String(255), nullable=True)
smtp_password: Mapped[str | None] = mapped_column(String(500), nullable=True)
smtp_security: Mapped[str] = mapped_column(String(20), nullable=False, default="SSL") # SSL|STARTTLS|NONE
smtp_timeout_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=20)
from_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
from_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
reply_to_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
send_auth_emails: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
class EmailTemplate(CommonBase):
__tablename__ = "email_templates"
__table_args__ = (
+150 -1
View File
@@ -11,7 +11,7 @@ from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.modules.email_integration.models import EmailLog, EmailSetting, EmailTemplate
from app.modules.email_integration.models import EmailLog, EmailSetting, EmailTemplate, PlatformEmailSetting
from app.modules.email_integration.attachment_service import EmailAttachment
from app.modules.core.iam.models import User
from app.core.settings import get_settings
@@ -232,6 +232,38 @@ def seed_default_email_templates(db: Session, tenant_id: int | None = None, bran
existing_codes.add(code)
def get_platform_email_setting(db: Session) -> PlatformEmailSetting | None:
return db.execute(
select(PlatformEmailSetting).order_by(PlatformEmailSetting.id.asc())
).scalars().first()
def get_or_create_platform_email_setting(db: Session, actor_user_id: int | None = None) -> PlatformEmailSetting:
row = get_platform_email_setting(db)
if row:
return row
settings = get_settings()
row = PlatformEmailSetting(
smtp_host=getattr(settings, "SMTP_HOST", None) or "mail.vavalam.com",
smtp_port=int(getattr(settings, "SMTP_PORT", 465) or 465),
smtp_security="SSL",
smtp_username=getattr(settings, "SMTP_USERNAME", None) or "no-reply@vavalam.com",
smtp_password=getattr(settings, "SMTP_PASSWORD", None),
from_email=getattr(settings, "SMTP_FROM_EMAIL", None) or "no-reply@vavalam.com",
from_name=getattr(settings, "SMTP_FROM_NAME", None) or "ERP Platform",
reply_to_email=getattr(settings, "SMTP_FROM_EMAIL", None) or "no-reply@vavalam.com",
send_auth_emails=True,
is_active=True,
created_by_user_id=actor_user_id,
updated_by_user_id=actor_user_id,
)
db.add(row)
db.flush()
return row
def get_email_setting(db: Session, tenant_id: int | None, branch_id: int | None) -> EmailSetting | None:
if tenant_id is None:
return None
@@ -585,6 +617,123 @@ def process_pending_email_queue(
db.flush()
return result
def send_platform_email_log_now(db: Session, log: EmailLog) -> EmailLog:
"""Attempt immediate send through System Admin platform SMTP."""
setting = get_platform_email_setting(db)
now = datetime.now(timezone.utc)
log.processing_started_at = now
log.last_attempt_at = now
log.attempt_count = int(getattr(log, "attempt_count", 0) or 0) + 1
if not setting or not setting.is_active:
log.status = "SKIPPED"
log.error_message = "Platform SMTP settings not configured or inactive."
log.is_retryable = False
log.processing_started_at = None
db.flush()
return log
try:
_send_smtp(setting, log.recipient_email, log.subject, log.body or "", is_html=False)
log.status = "SENT"
log.sent_at = datetime.now(timezone.utc)
log.error_message = None
log.next_retry_at = None
log.processing_started_at = None
log.is_retryable = False
except Exception as exc:
log.status = "FAILED"
log.error_message = str(exc)
log.processing_started_at = None
log.is_retryable = False
log.next_retry_at = None
db.flush()
return log
def send_platform_template_email(
db: Session,
*,
recipient_email: str,
template_code: str,
context: dict[str, Any] | None = None,
related_module: str | None = None,
related_id: int | None = None,
send_immediately: bool = True,
queue_priority: int = 10,
) -> EmailLog:
"""Queue/send a platform email without tenant SMTP dependency."""
context = dict(context or {})
context.setdefault("firm_name", "ERP Platform")
context.setdefault("support_email", "")
template = _get_template(db, None, None, template_code)
if not template:
subject = template_code
body = ""
else:
subject = render_template_text(template.subject_template, context)
body = render_template_text(template.body_template, context)
log = EmailLog(
tenant_id=None,
branch_id=None,
recipient_email=recipient_email,
subject=subject[:500],
body=body,
status="PENDING",
related_module=related_module,
related_id=related_id,
template_code=template_code,
queued_at=datetime.now(timezone.utc),
max_attempts=1,
queue_priority=int(queue_priority or 10),
is_retryable=False,
)
db.add(log)
db.flush()
setting = get_platform_email_setting(db)
if not setting or not setting.is_active:
log.status = "SKIPPED"
log.error_message = "Platform SMTP settings not configured or inactive."
db.flush()
return log
if template_code.upper().startswith("AUTH_") and not getattr(setting, "send_auth_emails", True):
log.status = "SKIPPED"
log.error_message = "Platform authentication/invite emails are disabled."
db.flush()
return log
if send_immediately:
send_platform_email_log_now(db, log)
return log
def send_platform_user_invite_email(db: Session, *, user: User, invite_token: str, firm_name: str | None = None) -> EmailLog | None:
if not getattr(user, "email", None):
return None
invite_link = f"{_public_base_url()}/invite/accept?token={invite_token}"
return send_platform_template_email(
db,
recipient_email=str(user.email),
template_code="AUTH_USER_INVITE",
context={
"firm_name": firm_name or "ERP Platform",
"user_name": _user_display_name(user),
"user_email": str(user.email),
"invite_link": invite_link,
"expiry_hours": str(get_settings().INVITE_TOKEN_HOURS),
"support_email": "",
},
related_module="wizard_firm_invite",
related_id=int(user.id),
queue_priority=5,
)
def send_template_email(
db: Session,
*,
@@ -0,0 +1,78 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<div class="rounded-2xl bg-white p-6 shadow-card ring-1 ring-slate-200">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 class="text-xl font-semibold text-slate-900">Platform SMTP Settings</h2>
<p class="mt-1 text-sm text-slate-500">System Admin SMTP used for firm creation invites, primary Firm Admin onboarding, password/reset emails and platform notices before a firm configures its own SMTP.</p>
</div>
<div class="flex gap-2">
<a href="/wizards/system/firm/new" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Create Firm</a>
<a href="/email/logs" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Email Logs</a>
</div>
</div>
{% if flash %}
<div class="mt-4 rounded-2xl border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">{{ flash }}</div>
{% endif %}
</div>
<form method="post" action="/email/platform-smtp" class="space-y-6">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<section class="rounded-2xl bg-white p-6 shadow-card ring-1 ring-slate-200">
<h3 class="text-base font-semibold text-slate-900">Outgoing SMTP</h3>
<div class="mt-4 grid gap-4 md:grid-cols-3">
<label class="block"><span class="text-sm font-medium text-slate-700">SMTP Host</span><input name="smtp_host" value="{{ setting.smtp_host or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2" placeholder="mail.vavalam.com"></label>
<label class="block"><span class="text-sm font-medium text-slate-700">SMTP Port</span><input type="number" name="smtp_port" value="{{ setting.smtp_port or 465 }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></label>
<label class="block"><span class="text-sm font-medium text-slate-700">Security</span><select name="smtp_security" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"><option value="SSL" {% if setting.smtp_security=='SSL' %}selected{% endif %}>SSL</option><option value="STARTTLS" {% if setting.smtp_security=='STARTTLS' %}selected{% endif %}>STARTTLS / TLS</option><option value="NONE" {% if setting.smtp_security=='NONE' %}selected{% endif %}>None</option></select></label>
<label class="block"><span class="text-sm font-medium text-slate-700">SMTP Username</span><input name="smtp_username" value="{{ setting.smtp_username or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2" placeholder="no-reply@vavalam.com"></label>
<label class="block"><span class="text-sm font-medium text-slate-700">SMTP Password / App Password</span><input type="password" name="smtp_password" value="" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2" placeholder="Leave blank to keep existing"></label>
<label class="block"><span class="text-sm font-medium text-slate-700">Timeout Seconds</span><input type="number" min="5" max="120" name="smtp_timeout_seconds" value="{{ setting.smtp_timeout_seconds or 20 }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></label>
<label class="block"><span class="text-sm font-medium text-slate-700">From Email</span><input name="from_email" value="{{ setting.from_email or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2" placeholder="no-reply@vavalam.com"></label>
<label class="block"><span class="text-sm font-medium text-slate-700">From Name</span><input name="from_name" value="{{ setting.from_name or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2" placeholder="ERP Platform"></label>
<label class="block"><span class="text-sm font-medium text-slate-700">Reply-to Email</span><input name="reply_to_email" value="{{ setting.reply_to_email or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2" placeholder="support@vavalam.com"></label>
</div>
</section>
<section class="rounded-2xl bg-white p-6 shadow-card ring-1 ring-slate-200">
<h3 class="text-base font-semibold text-slate-900">Platform Email Controls</h3>
<div class="mt-4 grid gap-3 md:grid-cols-2">
<label class="flex items-start gap-3 rounded-xl border border-slate-200 p-3">
<input type="checkbox" name="is_active" value="1" class="mt-1" {% if setting.is_active %}checked{% endif %}>
<span><span class="block font-medium text-slate-800">Platform SMTP active</span><span class="text-xs text-slate-500">Master switch for System Admin platform emails.</span></span>
</label>
<label class="flex items-start gap-3 rounded-xl border border-slate-200 p-3">
<input type="checkbox" name="send_auth_emails" value="1" class="mt-1" {% if setting.send_auth_emails %}checked{% endif %}>
<span><span class="block font-medium text-slate-800">Send invite/auth emails</span><span class="text-xs text-slate-500">Firm Creation Wizard invite emails use this switch.</span></span>
</label>
</div>
<div class="mt-6 flex justify-end"><button class="rounded-xl bg-brand-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-brand-700">Save Platform SMTP</button></div>
</section>
</form>
<section class="rounded-2xl bg-white p-6 shadow-card ring-1 ring-slate-200">
<h3 class="text-base font-semibold text-slate-900">Send Test Email</h3>
<form method="post" action="/email/platform-smtp/test" class="mt-4 flex flex-col gap-3 sm:flex-row">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input name="test_email" value="{{ current_user.email }}" class="min-w-0 flex-1 rounded-xl border border-slate-300 px-3 py-2">
<button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Send Test</button>
</form>
</section>
<section class="rounded-2xl bg-white p-6 shadow-card ring-1 ring-slate-200">
<h3 class="text-base font-semibold text-slate-900">Recent Platform Email Attempts</h3>
<div class="mt-4 overflow-x-auto">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead><tr class="text-left text-slate-500"><th class="py-2 pr-4">Time</th><th class="py-2 pr-4">Recipient</th><th class="py-2 pr-4">Subject</th><th class="py-2 pr-4">Status</th><th class="py-2 pr-4">Error</th></tr></thead>
<tbody class="divide-y divide-slate-100">
{% for log in recent_logs %}
<tr><td class="py-2 pr-4 whitespace-nowrap">{{ log.created_at_utc }}</td><td class="py-2 pr-4">{{ log.recipient_email }}</td><td class="py-2 pr-4">{{ log.subject }}</td><td class="py-2 pr-4">{{ log.status }}</td><td class="py-2 pr-4 text-red-700">{{ log.error_message or '' }}</td></tr>
{% else %}
<tr><td colspan="5" class="py-4 text-slate-500">No platform email attempts yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
</div>
{% endblock %}
+110 -1
View File
@@ -12,7 +12,7 @@ from app.core.security.csrf import get_or_create_csrf_token, validate_csrf
from app.core.security.session_auth import get_current_user
from app.core.templating import templates
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
from app.modules.email_integration.models import EmailIncomingAttachment, EmailIncomingMessage, EmailLog, EmailSetting, EmailTemplate
from app.modules.email_integration.models import EmailIncomingAttachment, EmailIncomingMessage, EmailLog, EmailSetting, EmailTemplate, PlatformEmailSetting
from app.modules.email_integration.imap_service import fetch_incoming_emails
from app.modules.email_integration.mapping_service import apply_email_mapping, auto_map_unmapped_emails
from app.modules.billing.models import BillingInvoice
@@ -23,6 +23,8 @@ from app.modules.email_integration.services import (
seed_default_email_templates,
send_template_email,
process_pending_email_queue,
get_or_create_platform_email_setting,
send_platform_template_email,
)
router = APIRouter(prefix="/email", tags=["email-integration-ui"])
@@ -32,6 +34,11 @@ def _redirect_login():
return RedirectResponse(url="/login", status_code=303)
def _user_is_system_admin(db, user) -> bool:
return "System Admin" in set(get_user_roles(db, int(user.id)))
def _user_can_manage_email(db, user) -> bool:
roles = set(get_user_roles(db, int(user.id)))
perms = set(get_user_permissions(db, int(user.id)))
@@ -58,6 +65,108 @@ def _ctx(request: Request, db, user, **extra):
return ctx
@router.get("/platform-smtp")
def platform_smtp_page(request: Request, flash: str | None = None):
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
if not user:
return _redirect_login()
if not _user_is_system_admin(db, user):
return forbidden_response(request, "Access denied: platform SMTP is restricted to System Admin")
setting = get_or_create_platform_email_setting(db, actor_user_id=int(user.id))
db.commit()
recent_logs = db.execute(
select(EmailLog)
.where(EmailLog.tenant_id.is_(None), EmailLog.related_module.in_(["platform_smtp_test", "wizard_firm_invite"]))
.order_by(desc(EmailLog.created_at_utc))
.limit(10)
).scalars().all()
return templates.TemplateResponse(
"modules/email_integration/templates/email_integration/platform_smtp.html",
_ctx(request, db, user, title="Platform SMTP Settings", setting=setting, recent_logs=recent_logs, flash=flash),
)
finally:
db.close()
@router.post("/platform-smtp")
def save_platform_smtp(
request: Request,
csrf_token: str = Form(...),
smtp_host: str = Form(""),
smtp_port: int = Form(465),
smtp_username: str = Form(""),
smtp_password: str = Form(""),
smtp_security: str = Form("SSL"),
smtp_timeout_seconds: int = Form(20),
from_email: str = Form(""),
from_name: str = Form(""),
reply_to_email: str = Form(""),
send_auth_emails: str | None = Form(None),
is_active: str | None = Form(None),
):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
if not user:
return _redirect_login()
if not _user_is_system_admin(db, user):
return forbidden_response(request, "Access denied: platform SMTP is restricted to System Admin")
setting = get_or_create_platform_email_setting(db, actor_user_id=int(user.id))
setting.smtp_host = smtp_host.strip() or None
setting.smtp_port = int(smtp_port or 465)
setting.smtp_username = smtp_username.strip() or None
if smtp_password.strip():
setting.smtp_password = smtp_password.strip()
setting.smtp_security = (smtp_security or "SSL").upper()
setting.smtp_timeout_seconds = max(5, min(int(smtp_timeout_seconds or 20), 120))
setting.from_email = from_email.strip() or None
setting.from_name = from_name.strip() or None
setting.reply_to_email = reply_to_email.strip() or None
setting.send_auth_emails = bool(send_auth_emails)
setting.is_active = bool(is_active)
setting.updated_by_user_id = int(user.id)
db.commit()
return RedirectResponse(url="/email/platform-smtp?flash=Platform SMTP settings saved.", status_code=303)
finally:
db.close()
@router.post("/platform-smtp/test")
def test_platform_smtp(request: Request, csrf_token: str = Form(...), test_email: str = Form("")):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
if not user:
return _redirect_login()
if not _user_is_system_admin(db, user):
return forbidden_response(request, "Access denied: platform SMTP is restricted to System Admin")
recipient = (test_email or getattr(user, "email", "")).strip()
send_platform_template_email(
db,
recipient_email=recipient,
template_code="AUTH_USER_INVITE",
context={
"firm_name": "ERP Platform",
"user_name": getattr(user, "full_name", None) or recipient,
"invite_link": "This is a platform SMTP test email. No action is required.",
"expiry_hours": "0",
},
related_module="platform_smtp_test",
related_id=int(user.id),
queue_priority=1,
)
db.commit()
return RedirectResponse(url="/email/platform-smtp?flash=Test email attempted. Check recent logs below.", status_code=303)
finally:
db.close()
@router.get("/audit")