vignette/apps/api/app/services/notifications.py
2026-06-29 17:07:01 +09:00

744 lines
27 KiB
Python

"""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
logger = logging.getLogger(__name__)
NotificationKind = Literal["account_pending_approval", "session_review_ready", "admin_test_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:
"""Create notification queue tables when the DB role allows DDL."""
get_pool()
async with acquire(role="admin") as conn:
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');
"""
)
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 admin test email through the normal delivery path."""
payload = {
"requested_by": actor_email,
"notifications_url": _frontend_url("/admin"),
}
recipients = await _admin_approval_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]:
super_admin_emails = sorted({_normalize_email(value) for value in settings.auth_super_admin_emails})
async with acquire(role="admin") as conn:
rows = await conn.fetch(
"""
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 <> ''
AND (
u.role = 'admin'
OR u.admin_access
OR lower(u.email) = ANY($1::text[])
)
AND COALESCE((p.notifications->>'account_approval')::boolean, true)
ORDER BY u.email
""",
super_admin_emails,
)
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(
"""
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 <> ''
AND (
u.role = 'admin'
OR lower(u.email) = ANY($1::text[])
OR (
u.role = 'instructor'
AND ($2 = '' OR u.cohort = $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"""
<p style="margin:0 0 20px;font-size:15px;line-height:1.7;color:#5a6663;">
승인 대기 중인 신규 사용자가 있습니다. 가입 승인 화면에서 수업 또는 연구 참여 범위를 확인한 뒤 승인 상태를 결정해 주세요.
</p>
{_info_box([
("사용자", display_name),
("이메일", email),
("요청 역할", role),
])}
{_button("가입 승인 확인하기", approval_url)}
<p style="margin:24px 0 0;font-size:12px;line-height:1.6;color:#93a09c;">
계정 승인 전까지 해당 사용자는 Vignette 대기 화면만 볼 수 있습니다.
</p>
"""
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"""
<p style="margin:0 0 20px;font-size:15px;line-height:1.7;color:#5a6663;">
종료된 학습 회기가 교수자 검토 대기 상태입니다. 회기 리뷰 화면에서 요약과 근거를 확인한 뒤 검토 상태를 남겨 주세요.
</p>
{_info_box([
("학습자", learner_label),
("내담자", persona_name),
("회기", f"{session_no}회기" if session_no else "종료 회기"),
("종료 시각", ended_at or "기록됨"),
])}
{_button("회기 검토하기", review_url)}
<p style="margin:24px 0 0;font-size:12px;line-height:1.6;color:#93a09c;">
민감한 회기 내용은 메일에 포함하지 않았습니다. 로그인 후 Vignette에서 확인해 주세요.
</p>
"""
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"""
<p style="margin:0 0 20px;font-size:15px;line-height:1.7;color:#5a6663;">
관리자 메일 알림이 정상적으로 연결되었습니다. 이 메일은 실제 가입 승인이나 회기 검토 요청이 아니라 발송 경로 확인용 테스트입니다.
</p>
{_info_box([
("요청자", requested_by),
("용도", "운영 메일 발송 테스트"),
])}
{_button("알림 상태 확인하기", notifications_url)}
<p style="margin:24px 0 0;font-size:12px;line-height:1.6;color:#93a09c;">
이후 가입 승인 요청과 회기 검토 요청도 같은 메일 템플릿과 발송 큐를 사용합니다.
</p>
"""
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"""<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{_h(title)}</title>
</head>
<body style="margin:0;background:#fbfaf8;font-family:Pretendard,-apple-system,BlinkMacSystemFont,'Apple SD Gothic Neo','Noto Sans KR',sans-serif;color:#1c2a2a;">
<span style="display:none!important;visibility:hidden;opacity:0;color:transparent;height:0;width:0;overflow:hidden;">{_h(preheader)}</span>
<div style="max-width:640px;margin:0 auto;padding:32px 20px;">
<div style="font-size:14px;font-weight:800;color:#3e7a6e;margin-bottom:16px;letter-spacing:0;">Vignette</div>
<section style="background:#ffffff;border:1px solid #e5e1da;border-radius:8px;padding:28px;box-shadow:0 1px 2px rgba(28,42,42,0.05);">
<p style="margin:0 0 8px;font-size:12px;font-weight:800;color:#5a6663;">{_h(kicker)}</p>
<h1 style="margin:0 0 14px;font-size:24px;line-height:1.35;color:#1c2a2a;font-weight:800;letter-spacing:0;">{_h(title)}</h1>
{body}
</section>
<p style="margin:16px 0 0;font-size:11px;line-height:1.6;color:#93a09c;">
이 메일은 Vignette 운영 알림입니다. 버튼이 열리지 않으면 링크를 복사해 브라우저 주소창에 붙여 넣어 주세요.
</p>
</div>
</body>
</html>"""
def _info_box(items: list[tuple[str, str]]) -> str:
rows = "".join(
f"""
<tr>
<td style="padding:5px 0;width:92px;color:#5a6663;font-size:13px;">{_h(label)}</td>
<td style="padding:5px 0;color:#1c2a2a;font-size:14px;font-weight:700;">{_h(value)}</td>
</tr>
"""
for label, value in items
if value
)
return f"""
<div style="background:#eef4f2;border-radius:8px;padding:14px 16px;margin-bottom:24px;">
<table role="presentation" cellpadding="0" cellspacing="0" style="width:100%;border-collapse:collapse;">
{rows}
</table>
</div>
"""
def _button(label: str, url: str) -> str:
safe_url = _h(url)
return f"""
<a href="{safe_url}"
style="display:inline-block;background:#3e7a6e;color:#fbfaf8;text-decoration:none;border-radius:8px;padding:12px 18px;font-size:14px;font-weight:800;">
{_h(label)}
</a>
"""
def _frontend_url(path: str) -> str:
base = settings.frontend_base_url.rstrip("/") or "http://localhost:5173"
suffix = "/" + path.lstrip("/")
return f"{base}{suffix}"
def _recipient_from_row(row: Any) -> NotificationRecipient:
return NotificationRecipient(
user_id=str(row["user_id"]) if row["user_id"] else None,
email=str(row["email"]),
display_name=str(row["display_name"] or row["email"]),
role=str(row["role"] or ""),
)
def _normalize_email(value: str) -> str:
return value.strip().lower()
def _role_label(value: str) -> str:
if value in {"teacher", "instructor"}:
return "교수자"
if value == "admin":
return "관리자"
return "학습자"
def _iso_text(value: Any) -> str:
if value is None:
return ""
iso = getattr(value, "isoformat", None)
if callable(iso):
return iso()
return str(value)
def _message_id_domain(email: str) -> str:
if "@" in email:
return email.rsplit("@", 1)[1]
return "vignette.local"
def _h(value: str) -> str:
return html.escape(value, quote=True)
class NotificationSkipped(RuntimeError):
"""Raised when a delivery is intentionally not sent."""