"""Operational email notifications for review and account-approval work.""" from __future__ import annotations import asyncio import html import logging import smtplib import ssl from dataclasses import dataclass from email.message import EmailMessage from email.utils import formataddr, make_msgid from typing import Any, Literal from uuid import uuid4 from ..config import settings from ..db import acquire, get_pool from ..runtime_schema import ( NOTIFICATION_SCHEMA_CONTRACT, runtime_schema_bootstrap_required, schema_contract_ready, ) logger = logging.getLogger(__name__) NotificationKind = Literal[ "account_pending_approval", "session_review_ready", "admin_test_email" ] ApprovalRecipientScope = Literal["admin", "super_admin"] ACTIVE_NOTIFICATION_RECIPIENTS_SQL = """ SELECT DISTINCT u.user_id, u.email, COALESCE(NULLIF(u.display_name, ''), u.email) AS display_name, u.role FROM app.app_user u LEFT JOIN app.user_preferences p ON p.user_id = u.user_id WHERE u.is_active AND u.account_status = 'approved' AND u.email IS NOT NULL AND u.email <> '' """ @dataclass(slots=True) class NotificationRecipient: user_id: str | None email: str display_name: str role: str @dataclass(slots=True) class RenderedEmail: subject: str preheader: str html: str text: str async def ensure_notification_tables() -> None: """Verify notification tables, with DDL repair restricted to local development.""" get_pool() async with acquire(role="admin") as conn: ready = await schema_contract_ready(conn, NOTIFICATION_SCHEMA_CONTRACT) if not runtime_schema_bootstrap_required( NOTIFICATION_SCHEMA_CONTRACT, ready=ready ): return await conn.execute( """ CREATE TABLE IF NOT EXISTS app.notification_event ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), kind TEXT NOT NULL CHECK ( kind IN ('account_pending_approval','session_review_ready','admin_test_email') ), actor_user_id UUID REFERENCES app.app_user(user_id) ON DELETE SET NULL, subject_user_id UUID REFERENCES app.app_user(user_id) ON DELETE SET NULL, session_id UUID REFERENCES app.sessions(id) ON DELETE CASCADE, idempotency_key TEXT NOT NULL UNIQUE, payload JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ) """ ) await conn.execute( """ CREATE TABLE IF NOT EXISTS app.notification_delivery ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), event_id UUID NOT NULL REFERENCES app.notification_event(id) ON DELETE CASCADE, channel TEXT NOT NULL DEFAULT 'email' CHECK (channel IN ('email')), recipient_user_id UUID REFERENCES app.app_user(user_id) ON DELETE SET NULL, recipient_email TEXT NOT NULL, recipient_name TEXT NOT NULL DEFAULT '', recipient_role TEXT NOT NULL DEFAULT '', subject TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'queued' CHECK ( status IN ('queued','sending','sent','failed','skipped') ), attempts INTEGER NOT NULL DEFAULT 0, next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(), sent_at TIMESTAMPTZ, last_error TEXT, provider_message_id TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (event_id, channel, recipient_email) ) """ ) await conn.execute( """ ALTER TABLE app.notification_event DROP CONSTRAINT IF EXISTS notification_event_kind_check; ALTER TABLE app.notification_event ADD CONSTRAINT notification_event_kind_check CHECK (kind IN ('account_pending_approval','session_review_ready','admin_test_email')); """ ) await conn.execute( """ CREATE INDEX IF NOT EXISTS idx_notification_event_created ON app.notification_event(created_at DESC); CREATE INDEX IF NOT EXISTS idx_notification_delivery_queue ON app.notification_delivery(status, next_attempt_at, created_at) WHERE status IN ('queued','failed'); CREATE INDEX IF NOT EXISTS idx_notification_delivery_event ON app.notification_delivery(event_id, created_at); """ ) await conn.execute( """ ALTER TABLE app.notification_event ENABLE ROW LEVEL SECURITY; ALTER TABLE app.notification_delivery ENABLE ROW LEVEL SECURITY; DROP POLICY IF EXISTS p_notification_event_admin_all ON app.notification_event; DROP POLICY IF EXISTS p_notification_delivery_admin_all ON app.notification_delivery; CREATE POLICY p_notification_event_admin_all ON app.notification_event FOR ALL USING (app.current_role_name() = 'admin') WITH CHECK (app.current_role_name() = 'admin'); CREATE POLICY p_notification_delivery_admin_all ON app.notification_delivery FOR ALL USING (app.current_role_name() = 'admin') WITH CHECK (app.current_role_name() = 'admin'); """ ) if not await schema_contract_ready(conn, NOTIFICATION_SCHEMA_CONTRACT): raise RuntimeError( "notification development schema bootstrap did not satisfy readiness" ) def schedule_delivery_flush() -> None: """Best-effort background drain; failed SMTP remains queued for a worker.""" if settings.notification_email_provider == "disabled": return try: asyncio.get_running_loop().create_task( process_queued_email_notifications(limit=10) ) except RuntimeError: return async def enqueue_account_pending_approval( *, user_id: str, email: str, display_name: str, role: str, ) -> None: """Notify administrators that a newly signed-in account needs approval.""" if not user_id or not email: return payload = { "user_id": user_id, "email": email, "display_name": display_name or email, "role": role, "approval_url": _frontend_url("/admin/users"), } recipients = await _admin_approval_recipients() await _enqueue_event( kind="account_pending_approval", idempotency_key=f"account_pending_approval:{user_id}", subject_user_id=user_id, session_id=None, payload=payload, recipients=recipients, ) schedule_delivery_flush() async def enqueue_session_review_ready(*, session_id: str) -> None: """Notify assigned teachers/admins that an ended session is ready to review.""" if not session_id: return payload, recipients = await _session_review_payload_and_recipients(session_id) if payload is None: return await _enqueue_event( kind="session_review_ready", idempotency_key=f"session_review_ready:{session_id}", subject_user_id=str(payload.get("learner_id") or "") or None, session_id=session_id, payload=payload, recipients=recipients, ) schedule_delivery_flush() async def enqueue_admin_test_email( *, actor_user_id: str | None = None, actor_email: str = "", ) -> int: """Queue one explicit super-admin test email through the normal delivery path.""" payload = { "requested_by": actor_email, "notifications_url": _frontend_url("/admin"), } recipients = await _super_admin_recipients() await _enqueue_event( kind="admin_test_email", idempotency_key=f"admin_test_email:{uuid4()}", subject_user_id=actor_user_id, session_id=None, payload=payload, recipients=recipients, ) schedule_delivery_flush() return len(recipients) async def process_queued_email_notifications(*, limit: int = 25) -> dict[str, int]: """Send queued email deliveries and record durable delivery state.""" get_pool() processed = 0 sent = 0 failed = 0 skipped = 0 async with acquire(role="admin") as conn: rows = await conn.fetch( """ SELECT d.id, d.recipient_email, d.recipient_name, d.attempts, e.kind, e.payload FROM app.notification_delivery d JOIN app.notification_event e ON e.id = d.event_id WHERE d.channel = 'email' AND d.status IN ('queued','failed') AND d.next_attempt_at <= now() AND d.attempts < $1 ORDER BY d.created_at LIMIT $2 """, settings.notification_email_max_attempts, limit, ) for row in rows: delivery_id = str(row["id"]) processed += 1 await _mark_delivery_sending(delivery_id) rendered = render_email(row["kind"], row["payload"] or {}) try: provider_id = await asyncio.to_thread( _send_rendered_email, recipient_email=str(row["recipient_email"]), recipient_name=str(row["recipient_name"] or ""), rendered=rendered, ) except NotificationSkipped as exc: skipped += 1 await _mark_delivery_skipped(delivery_id, str(exc)) except Exception as exc: # SMTP/library errors should not break app flows. failed += 1 await _mark_delivery_failed(delivery_id, str(exc)) else: sent += 1 await _mark_delivery_sent(delivery_id, provider_id) return {"processed": processed, "sent": sent, "failed": failed, "skipped": skipped} def render_email(kind: str, payload: dict[str, Any]) -> RenderedEmail: if kind == "account_pending_approval": return _render_account_pending_approval(payload) if kind == "session_review_ready": return _render_session_review_ready(payload) if kind == "admin_test_email": return _render_admin_test_email(payload) raise ValueError(f"unsupported notification kind: {kind}") async def _enqueue_event( *, kind: NotificationKind, idempotency_key: str, subject_user_id: str | None, session_id: str | None, payload: dict[str, Any], recipients: list[NotificationRecipient], ) -> None: if not recipients: logger.warning("notification %s has no recipients", idempotency_key) get_pool() rendered = render_email(kind, payload) async with acquire(role="admin") as conn: row = await conn.fetchrow( """ INSERT INTO app.notification_event ( kind, subject_user_id, session_id, idempotency_key, payload ) VALUES ($1, $2::uuid, $3::uuid, $4, $5::jsonb) ON CONFLICT (idempotency_key) DO UPDATE SET payload = app.notification_event.payload RETURNING id """, kind, subject_user_id, session_id, idempotency_key, payload, ) if row is None: return event_id = str(row["id"]) for recipient in recipients: await conn.execute( """ INSERT INTO app.notification_delivery ( event_id, recipient_user_id, recipient_email, recipient_name, recipient_role, subject ) VALUES ($1::uuid, $2::uuid, $3, $4, $5, $6) ON CONFLICT (event_id, channel, recipient_email) DO NOTHING """, event_id, recipient.user_id, recipient.email, recipient.display_name, recipient.role, rendered.subject, ) async def _admin_approval_recipients() -> list[NotificationRecipient]: return await _approval_recipients(scope="admin") async def _super_admin_recipients() -> list[NotificationRecipient]: return await _approval_recipients(scope="super_admin") async def _approval_recipients( *, scope: ApprovalRecipientScope ) -> list[NotificationRecipient]: """가입 승인 알림 수신 정책을 한 쿼리에서 소유한다.""" super_admin_emails = sorted( {_normalize_email(value) for value in settings.auth_super_admin_emails} ) super_admin_only = scope == "super_admin" if super_admin_only and not super_admin_emails: return [] async with acquire(role="admin") as conn: rows = await conn.fetch( ACTIVE_NOTIFICATION_RECIPIENTS_SQL + """ AND ( lower(u.email) = ANY($1::text[]) OR ( NOT $2::boolean AND (u.role = 'admin' OR u.admin_access) ) ) AND COALESCE((p.notifications->>'account_approval')::boolean, true) ORDER BY u.email """, super_admin_emails, super_admin_only, ) return [_recipient_from_row(row) for row in rows] async def _session_review_payload_and_recipients( session_id: str, ) -> tuple[dict[str, Any] | None, list[NotificationRecipient]]: super_admin_emails = sorted( {_normalize_email(value) for value in settings.auth_super_admin_emails} ) async with acquire(role="admin") as conn: session = await conn.fetchrow( """ SELECT s.id, s.session_no, s.ended_at, s.learner_id, s.persona_id, learner.email AS learner_email, COALESCE(NULLIF(learner.display_name, ''), learner.email, '학습자') AS learner_label, learner.cohort AS learner_cohort, COALESCE(NULLIF(pc.display_name, ''), s.persona_id::text) AS persona_name FROM app.sessions s LEFT JOIN app.app_user learner ON learner.user_id = s.learner_id LEFT JOIN app.persona_card pc ON pc.persona_id = s.persona_id AND pc.version = s.persona_version WHERE s.id = $1::uuid AND s.ended_at IS NOT NULL """, session_id, ) if session is None: return None, [] learner_cohort = str(session["learner_cohort"] or "") rows = await conn.fetch( ACTIVE_NOTIFICATION_RECIPIENTS_SQL + """ AND ( u.role = 'admin' OR lower(u.email) = ANY($1::text[]) OR ( u.role = 'instructor' AND ( $2 = '' OR string_to_array(COALESCE(u.cohort, ''), ',') && string_to_array($2, ',') ) ) ) AND COALESCE((p.notifications->>'learner_progress')::boolean, true) ORDER BY u.email """, super_admin_emails, learner_cohort, ) payload = { "session_id": str(session["id"]), "session_no": int(session["session_no"] or 0), "ended_at": _iso_text(session["ended_at"]), "learner_id": str(session["learner_id"] or ""), "learner_email": str(session["learner_email"] or ""), "learner_label": str(session["learner_label"] or "학습자"), "learner_cohort": learner_cohort, "persona_id": str(session["persona_id"] or ""), "persona_name": str(session["persona_name"] or "내담자"), "review_url": _frontend_url(f"/teach/session/{session_id}/review"), } return payload, [_recipient_from_row(row) for row in rows] def _send_rendered_email( *, recipient_email: str, recipient_name: str, rendered: RenderedEmail, ) -> str: if settings.notification_email_provider == "disabled": raise NotificationSkipped("email provider is disabled") if settings.notification_email_provider != "smtp": raise NotificationSkipped( f"unsupported provider: {settings.notification_email_provider}" ) if not settings.smtp_host.strip() or not settings.smtp_from_email.strip(): raise NotificationSkipped("SMTP host/from email is not configured") message_id = make_msgid(domain=_message_id_domain(settings.smtp_from_email)) msg = EmailMessage() msg["Subject"] = rendered.subject msg["From"] = formataddr((settings.smtp_from_name, settings.smtp_from_email)) msg["To"] = formataddr((recipient_name, recipient_email)) msg["Message-ID"] = message_id msg.set_content(rendered.text) msg.add_alternative(rendered.html, subtype="html") if settings.smtp_ssl: context = ssl.create_default_context() with smtplib.SMTP_SSL( settings.smtp_host, settings.smtp_port, context=context, timeout=15 ) as smtp: _smtp_login_if_needed(smtp) smtp.send_message(msg) else: with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=15) as smtp: if settings.smtp_starttls: smtp.starttls(context=ssl.create_default_context()) _smtp_login_if_needed(smtp) smtp.send_message(msg) return message_id def _smtp_login_if_needed(smtp: smtplib.SMTP) -> None: if settings.smtp_username: smtp.login(settings.smtp_username, settings.smtp_password) async def _mark_delivery_sending(delivery_id: str) -> None: async with acquire(role="admin") as conn: await conn.execute( """ UPDATE app.notification_delivery SET status = 'sending', attempts = attempts + 1, updated_at = now() WHERE id = $1::uuid """, delivery_id, ) async def _mark_delivery_sent(delivery_id: str, provider_id: str) -> None: async with acquire(role="admin") as conn: await conn.execute( """ UPDATE app.notification_delivery SET status = 'sent', sent_at = now(), last_error = NULL, provider_message_id = $2, updated_at = now() WHERE id = $1::uuid """, delivery_id, provider_id, ) async def _mark_delivery_skipped(delivery_id: str, reason: str) -> None: async with acquire(role="admin") as conn: await conn.execute( """ UPDATE app.notification_delivery SET status = 'skipped', last_error = $2, updated_at = now() WHERE id = $1::uuid """, delivery_id, reason[:1000], ) async def _mark_delivery_failed(delivery_id: str, error: str) -> None: async with acquire(role="admin") as conn: await conn.execute( """ UPDATE app.notification_delivery SET status = 'failed', last_error = $2, next_attempt_at = now() + ($3::text || ' seconds')::interval, updated_at = now() WHERE id = $1::uuid """, delivery_id, error[:1000], settings.notification_email_retry_seconds, ) def _render_account_pending_approval(payload: dict[str, Any]) -> RenderedEmail: display_name = str( payload.get("display_name") or payload.get("email") or "신규 사용자" ) email = str(payload.get("email") or "") role = _role_label(str(payload.get("role") or "learner")) approval_url = str(payload.get("approval_url") or _frontend_url("/admin/users")) subject = "[Vignette] 새 가입 승인 요청이 있습니다" preheader = "승인 대기 중인 신규 사용자가 있습니다." html_body = f"""
승인 대기 중인 신규 사용자가 있습니다. 가입 승인 화면에서 수업 또는 연구 참여 범위를 확인한 뒤 승인 상태를 결정해 주세요.
{ _info_box( [ ("사용자", display_name), ("이메일", email), ("요청 역할", role), ] ) } {_button("가입 승인 확인하기", approval_url)}계정 승인 전까지 해당 사용자는 Vignette 대기 화면만 볼 수 있습니다.
""" html_doc = _email_shell( kicker="관리자 알림", title="새 가입 승인 요청이 있습니다.", preheader=preheader, body=html_body, ) text = ( "Vignette 관리자 알림\n\n" "새 가입 승인 요청이 있습니다.\n" f"사용자: {display_name}\n" f"이메일: {email}\n" f"요청 역할: {role}\n\n" f"가입 승인 확인하기: {approval_url}\n" ) return RenderedEmail(subject=subject, preheader=preheader, html=html_doc, text=text) def _render_session_review_ready(payload: dict[str, Any]) -> RenderedEmail: learner_label = str(payload.get("learner_label") or "학습자") persona_name = str(payload.get("persona_name") or "내담자") session_no = str(payload.get("session_no") or "") ended_at = str(payload.get("ended_at") or "") review_url = str(payload.get("review_url") or _frontend_url("/teach")) subject = "[Vignette] 검토할 회기가 있습니다" preheader = "종료된 학습 회기를 확인하고 검토 상태를 남겨 주세요." html_body = f"""종료된 학습 회기가 교수자 검토 대기 상태입니다. 회기 리뷰 화면에서 요약과 근거를 확인한 뒤 검토 상태를 남겨 주세요.
{ _info_box( [ ("학습자", learner_label), ("내담자", persona_name), ("회기", f"{session_no}회기" if session_no else "종료 회기"), ("종료 시각", ended_at or "기록됨"), ] ) } {_button("회기 검토하기", review_url)}민감한 회기 내용은 메일에 포함하지 않았습니다. 로그인 후 Vignette에서 확인해 주세요.
""" html_doc = _email_shell( kicker="교수자 검토 알림", title="검토할 회기가 있습니다.", preheader=preheader, body=html_body, ) text = ( "Vignette 교수자 검토 알림\n\n" "검토할 회기가 있습니다.\n" f"학습자: {learner_label}\n" f"내담자: {persona_name}\n" f"회기: {session_no}회기\n" f"종료 시각: {ended_at}\n\n" f"회기 검토하기: {review_url}\n" "\n민감한 회기 내용은 메일에 포함하지 않았습니다.\n" ) return RenderedEmail(subject=subject, preheader=preheader, html=html_doc, text=text) def _render_admin_test_email(payload: dict[str, Any]) -> RenderedEmail: requested_by = str(payload.get("requested_by") or "관리자") notifications_url = str(payload.get("notifications_url") or _frontend_url("/admin")) subject = "[Vignette] 메일 알림 테스트입니다" preheader = "관리자 메일 알림 경로가 정상적으로 연결되었는지 확인합니다." html_body = f"""관리자 메일 알림이 정상적으로 연결되었습니다. 이 메일은 실제 가입 승인이나 회기 검토 요청이 아니라 발송 경로 확인용 테스트입니다.
{ _info_box( [ ("요청자", requested_by), ("용도", "운영 메일 발송 테스트"), ] ) } {_button("알림 상태 확인하기", notifications_url)}이후 가입 승인 요청과 회기 검토 요청도 같은 메일 템플릿과 발송 큐를 사용합니다.
""" html_doc = _email_shell( kicker="관리자 테스트 알림", title="메일 알림 테스트입니다.", preheader=preheader, body=html_body, ) text = ( "Vignette 관리자 테스트 알림\n\n" "메일 알림 테스트입니다.\n" "관리자 메일 알림이 정상적으로 연결되었습니다.\n" f"요청자: {requested_by}\n\n" f"알림 상태 확인하기: {notifications_url}\n" ) return RenderedEmail(subject=subject, preheader=preheader, html=html_doc, text=text) def _email_shell(*, kicker: str, title: str, preheader: str, body: str) -> str: return f"""{_h(kicker)}
이 메일은 Vignette 운영 알림입니다. 버튼이 열리지 않으면 링크를 복사해 브라우저 주소창에 붙여 넣어 주세요.