메일 알림 시스템 추가
This commit is contained in:
parent
ddf12a851c
commit
3bf38c50df
22 changed files with 1769 additions and 31 deletions
|
|
@ -9,6 +9,7 @@ from __future__ import annotations
|
|||
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
import uuid
|
||||
|
|
@ -19,6 +20,9 @@ from .auth_types import AccountStatus, RoleName
|
|||
from .config import settings
|
||||
from .db import get_pool
|
||||
from .runtime_policy import require_runtime_fallback_allowed, runtime_fallback_allowed
|
||||
from .services import notifications
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
|
@ -351,6 +355,24 @@ async def _runtime_tables_ready(conn) -> bool:
|
|||
AND table_name = 'session_state'
|
||||
AND column_name = 'turns_in_stage'
|
||||
) AS has_state_columns,
|
||||
EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'app'
|
||||
AND table_name = 'turns'
|
||||
AND column_name = 'provider_events'
|
||||
) AS has_turn_provider_events,
|
||||
EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'app'
|
||||
AND table_name = 'session_review_status'
|
||||
AND column_name IN (
|
||||
'worksheet_status',
|
||||
'worksheet_note',
|
||||
'worksheet_reviewed_at'
|
||||
)
|
||||
GROUP BY table_schema, table_name
|
||||
HAVING count(*) = 3
|
||||
) AS has_session_review_worksheet_columns,
|
||||
(
|
||||
SELECT count(*) = 4
|
||||
FROM app.stage_def
|
||||
|
|
@ -463,6 +485,8 @@ async def _runtime_tables_ready(conn) -> bool:
|
|||
and row["has_engine_config"]
|
||||
and row["has_session_columns"]
|
||||
and row["has_state_columns"]
|
||||
and row["has_turn_provider_events"]
|
||||
and row["has_session_review_worksheet_columns"]
|
||||
and row["has_stage_defs"]
|
||||
and row["has_admin_health_event"]
|
||||
and row["has_admin_health_daily_rollup"]
|
||||
|
|
@ -1813,6 +1837,16 @@ async def create_session(
|
|||
reactivate=False,
|
||||
)
|
||||
)
|
||||
if managed.account_status == "pending":
|
||||
try:
|
||||
await notifications.enqueue_account_pending_approval(
|
||||
user_id=managed.user_id,
|
||||
email=managed.email,
|
||||
display_name=managed.display_name,
|
||||
role=managed.role,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("account approval notification enqueue failed: %s", exc)
|
||||
expires_at = time.time() + settings.session_ttl_seconds
|
||||
user = SessionUser(
|
||||
user_id=managed.user_id,
|
||||
|
|
|
|||
|
|
@ -219,6 +219,28 @@ class Settings(BaseSettings):
|
|||
validation_alias="USER_UPLOAD_DIR",
|
||||
)
|
||||
|
||||
# ── 운영 메일 알림 ─────────────────────────────────────
|
||||
notification_email_provider: Literal["disabled", "smtp"] = Field(
|
||||
default="disabled",
|
||||
validation_alias="NOTIFICATION_EMAIL_PROVIDER",
|
||||
)
|
||||
notification_email_max_attempts: int = Field(
|
||||
default=3,
|
||||
validation_alias="NOTIFICATION_EMAIL_MAX_ATTEMPTS",
|
||||
)
|
||||
notification_email_retry_seconds: int = Field(
|
||||
default=900,
|
||||
validation_alias="NOTIFICATION_EMAIL_RETRY_SECONDS",
|
||||
)
|
||||
smtp_host: str = Field(default="", validation_alias="SMTP_HOST")
|
||||
smtp_port: int = Field(default=587, validation_alias="SMTP_PORT")
|
||||
smtp_username: str = Field(default="", validation_alias="SMTP_USERNAME")
|
||||
smtp_password: str = Field(default="", validation_alias="SMTP_PASSWORD")
|
||||
smtp_from_email: str = Field(default="", validation_alias="SMTP_FROM_EMAIL")
|
||||
smtp_from_name: str = Field(default="Vignette", validation_alias="SMTP_FROM_NAME")
|
||||
smtp_starttls: bool = Field(default=True, validation_alias="SMTP_STARTTLS")
|
||||
smtp_ssl: bool = Field(default=False, validation_alias="SMTP_SSL")
|
||||
|
||||
# ── CORS (정적 프론트 + SSE 분리경로) ────────────────
|
||||
cors_origins: list[str] = Field(
|
||||
default=[
|
||||
|
|
@ -278,9 +300,16 @@ class Settings(BaseSettings):
|
|||
for origin in self.frontend_origin_map.values()
|
||||
):
|
||||
forbidden.append("FRONTEND_ORIGIN_MAP")
|
||||
if self.notification_email_provider == "smtp":
|
||||
if not self.smtp_host.strip():
|
||||
forbidden.append("SMTP_HOST")
|
||||
if not self.smtp_from_email.strip():
|
||||
forbidden.append("SMTP_FROM_EMAIL")
|
||||
if forbidden:
|
||||
joined = ", ".join(forbidden)
|
||||
raise ValueError(f"{joined} must be production-safe when ENVIRONMENT={self.environment}")
|
||||
if self.smtp_ssl and self.smtp_starttls:
|
||||
raise ValueError("SMTP_SSL and SMTP_STARTTLS cannot both be true")
|
||||
if self.auth_saml_enabled:
|
||||
missing_saml: list[str] = []
|
||||
if not self.saml_sp_entity_id.strip():
|
||||
|
|
|
|||
|
|
@ -151,7 +151,30 @@ async def healthcheck() -> bool:
|
|||
to_regclass('app.admin_engine_config') IS NOT NULL AS has_engine_config,
|
||||
to_regclass('app.admin_health_event') IS NOT NULL AS has_admin_health_event,
|
||||
to_regclass('app.admin_health_daily_rollup') IS NOT NULL AS has_admin_health_daily_rollup,
|
||||
to_regclass('app.support_ticket') IS NOT NULL AS has_support_ticket
|
||||
to_regclass('app.support_ticket') IS NOT NULL AS has_support_ticket,
|
||||
to_regclass('app.notification_event') IS NOT NULL AS has_notification_event,
|
||||
to_regclass('app.notification_delivery') IS NOT NULL AS has_notification_delivery,
|
||||
to_regclass('app.sessions') IS NOT NULL AS has_sessions,
|
||||
to_regclass('app.turns') IS NOT NULL AS has_turns,
|
||||
to_regclass('app.session_review_status') IS NOT NULL AS has_session_review_status,
|
||||
EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'app'
|
||||
AND table_name = 'turns'
|
||||
AND column_name = 'provider_events'
|
||||
) AS has_turn_provider_events,
|
||||
EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'app'
|
||||
AND table_name = 'session_review_status'
|
||||
AND column_name IN (
|
||||
'worksheet_status',
|
||||
'worksheet_note',
|
||||
'worksheet_reviewed_at'
|
||||
)
|
||||
GROUP BY table_schema, table_name
|
||||
HAVING count(*) = 3
|
||||
) AS has_session_review_worksheet_columns
|
||||
"""
|
||||
)
|
||||
return bool(
|
||||
|
|
@ -163,6 +186,13 @@ async def healthcheck() -> bool:
|
|||
and row["has_admin_health_event"]
|
||||
and row["has_admin_health_daily_rollup"]
|
||||
and row["has_support_ticket"]
|
||||
and row["has_notification_event"]
|
||||
and row["has_notification_delivery"]
|
||||
and row["has_sessions"]
|
||||
and row["has_turns"]
|
||||
and row["has_session_review_status"]
|
||||
and row["has_turn_provider_events"]
|
||||
and row["has_session_review_worksheet_columns"]
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ from .routes import share as share_routes
|
|||
from .routes import teacher as teacher_routes
|
||||
from .routes import users as user_routes
|
||||
from .routes import voice as voice_routes
|
||||
from .services.notifications import ensure_notification_tables
|
||||
from .services.voice import voice_service
|
||||
|
||||
|
||||
|
|
@ -44,6 +45,7 @@ async def lifespan(app: FastAPI):
|
|||
await init_pool()
|
||||
await ensure_runtime_tables()
|
||||
await ensure_review_tables()
|
||||
await ensure_notification_tables()
|
||||
if settings.auto_seed_personas:
|
||||
await materialize_seed_personas()
|
||||
await admin_routes.apply_engine_config_from_store()
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ from ..deps import Principal, require_admin_access
|
|||
from ..engine_client import engine_client
|
||||
from ..runtime_policy import require_runtime_fallback_allowed
|
||||
from ..services.voice import voice_service
|
||||
from ..services import evaluator, rag
|
||||
from ..services import evaluator, notifications, rag
|
||||
from ..store import store
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
|
@ -49,6 +49,7 @@ TicketCategory = Literal[
|
|||
]
|
||||
TicketPriority = Literal["low", "normal", "high", "urgent"]
|
||||
TicketStatus = Literal["open", "triaged", "in_progress", "resolved", "closed"]
|
||||
NotificationDeliveryStatus = Literal["queued", "sending", "sent", "failed", "skipped"]
|
||||
|
||||
|
||||
class AdminServiceHealth(BaseModel):
|
||||
|
|
@ -258,6 +259,46 @@ class AdminUserPatch(BaseModel):
|
|||
cohort_ids: list[str] | None = None
|
||||
|
||||
|
||||
class AdminNotificationDeliveryResponse(BaseModel):
|
||||
id: str
|
||||
event_id: str
|
||||
kind: str
|
||||
recipient_email: str
|
||||
recipient_name: str
|
||||
recipient_role: str
|
||||
subject: str
|
||||
status: NotificationDeliveryStatus
|
||||
attempts: int
|
||||
last_error: str | None = None
|
||||
provider_message_id: str | None = None
|
||||
created_at: float | None = None
|
||||
updated_at: float | None = None
|
||||
sent_at: float | None = None
|
||||
|
||||
|
||||
class AdminNotificationsResponse(BaseModel):
|
||||
provider: str
|
||||
smtp_configured: bool
|
||||
queued: int
|
||||
failed: int
|
||||
sent: int
|
||||
skipped: int
|
||||
deliveries: list[AdminNotificationDeliveryResponse]
|
||||
|
||||
|
||||
class AdminNotificationProcessResponse(BaseModel):
|
||||
processed: int
|
||||
sent: int
|
||||
failed: int
|
||||
skipped: int
|
||||
|
||||
|
||||
class AdminNotificationTestResponse(AdminNotificationProcessResponse):
|
||||
provider: str
|
||||
smtp_configured: bool
|
||||
recipients: int
|
||||
|
||||
|
||||
class RuntimeHealthMetrics(BaseModel):
|
||||
engine_latency_ms: float | None = None
|
||||
db_pool_size: int = 0
|
||||
|
|
@ -1009,6 +1050,25 @@ def _row_value(row, key: str, default=None):
|
|||
return default
|
||||
|
||||
|
||||
def _notification_delivery_response(row) -> AdminNotificationDeliveryResponse:
|
||||
return AdminNotificationDeliveryResponse(
|
||||
id=str(row["id"]),
|
||||
event_id=str(row["event_id"]),
|
||||
kind=str(row["kind"]),
|
||||
recipient_email=str(row["recipient_email"]),
|
||||
recipient_name=str(row["recipient_name"] or ""),
|
||||
recipient_role=str(row["recipient_role"] or ""),
|
||||
subject=str(row["subject"] or ""),
|
||||
status=row["status"],
|
||||
attempts=int(row["attempts"] or 0),
|
||||
last_error=str(row["last_error"] or "") or None,
|
||||
provider_message_id=str(row["provider_message_id"] or "") or None,
|
||||
created_at=_row_ts(row["created_at"]),
|
||||
updated_at=_row_ts(row["updated_at"]),
|
||||
sent_at=_row_ts(row["sent_at"]),
|
||||
)
|
||||
|
||||
|
||||
def _engine_config_from_row(row) -> AdminEngineConfigResponse:
|
||||
return AdminEngineConfigResponse(
|
||||
engine_mode=_normalize_engine_mode(row["engine_mode"]),
|
||||
|
|
@ -1456,6 +1516,101 @@ async def list_tickets(
|
|||
return _unavailable_tickets()
|
||||
|
||||
|
||||
@router.get("/notifications", response_model=AdminNotificationsResponse)
|
||||
async def list_notifications(
|
||||
principal: AdminPrincipal,
|
||||
limit: Annotated[int, Query(ge=1, le=200)] = 50,
|
||||
) -> AdminNotificationsResponse:
|
||||
"""Return recent operational email delivery state."""
|
||||
try:
|
||||
get_pool()
|
||||
async with acquire(role="admin") as conn:
|
||||
status_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT status, COUNT(*) AS count
|
||||
FROM app.notification_delivery
|
||||
GROUP BY status
|
||||
"""
|
||||
)
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT
|
||||
d.id,
|
||||
d.event_id,
|
||||
e.kind,
|
||||
d.recipient_email,
|
||||
d.recipient_name,
|
||||
d.recipient_role,
|
||||
d.subject,
|
||||
d.status,
|
||||
d.attempts,
|
||||
d.last_error,
|
||||
d.provider_message_id,
|
||||
d.created_at,
|
||||
d.updated_at,
|
||||
d.sent_at
|
||||
FROM app.notification_delivery d
|
||||
JOIN app.notification_event e ON e.id = d.event_id
|
||||
ORDER BY d.created_at DESC
|
||||
LIMIT $1
|
||||
""",
|
||||
limit,
|
||||
)
|
||||
except Exception:
|
||||
require_runtime_fallback_allowed("admin notifications")
|
||||
status_rows = []
|
||||
rows = []
|
||||
counts = {str(row["status"]): int(row["count"] or 0) for row in status_rows}
|
||||
return AdminNotificationsResponse(
|
||||
provider=settings.notification_email_provider,
|
||||
smtp_configured=bool(settings.smtp_host and settings.smtp_from_email),
|
||||
queued=counts.get("queued", 0),
|
||||
failed=counts.get("failed", 0),
|
||||
sent=counts.get("sent", 0),
|
||||
skipped=counts.get("skipped", 0),
|
||||
deliveries=[_notification_delivery_response(row) for row in rows],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/notifications/process", response_model=AdminNotificationProcessResponse)
|
||||
async def process_notifications(
|
||||
principal: AdminPrincipal,
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 25,
|
||||
) -> AdminNotificationProcessResponse:
|
||||
"""Drain queued email notifications once from the admin console/API."""
|
||||
try:
|
||||
result = await notifications.process_queued_email_notifications(limit=limit)
|
||||
except Exception:
|
||||
require_runtime_fallback_allowed("admin notification processing")
|
||||
result = {"processed": 0, "sent": 0, "failed": 0, "skipped": 0}
|
||||
return AdminNotificationProcessResponse(**result)
|
||||
|
||||
|
||||
@router.post("/notifications/test", response_model=AdminNotificationTestResponse)
|
||||
async def send_test_notification(
|
||||
principal: AdminPrincipal,
|
||||
) -> AdminNotificationTestResponse:
|
||||
"""Queue and process one explicit admin email test."""
|
||||
recipient_count = 0
|
||||
try:
|
||||
recipient_count = await notifications.enqueue_admin_test_email(
|
||||
actor_user_id=principal.user_id,
|
||||
actor_email=principal.email,
|
||||
)
|
||||
result = await notifications.process_queued_email_notifications(
|
||||
limit=max(1, min(100, recipient_count or 1))
|
||||
)
|
||||
except Exception:
|
||||
require_runtime_fallback_allowed("admin notification test")
|
||||
result = {"processed": 0, "sent": 0, "failed": 0, "skipped": 0}
|
||||
return AdminNotificationTestResponse(
|
||||
provider=settings.notification_email_provider,
|
||||
smtp_configured=bool(settings.smtp_host and settings.smtp_from_email),
|
||||
recipients=recipient_count,
|
||||
**result,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/tickets/{ticket_id}", response_model=AdminSupportTicketResponse)
|
||||
async def patch_ticket(
|
||||
ticket_id: str,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ from ..services import (
|
|||
guardrail,
|
||||
live_coach,
|
||||
memory,
|
||||
notifications,
|
||||
orchestrator,
|
||||
rag,
|
||||
session_digest_worker,
|
||||
|
|
@ -437,7 +438,7 @@ async def _warm_rag_caches(session_id: str, case_id: str, card) -> None:
|
|||
def _ensure_learner(principal: Principal) -> Principal:
|
||||
if principal.role == Role.LEARNER:
|
||||
return principal
|
||||
if principal.super_admin:
|
||||
if principal.can_access_role(Role.LEARNER):
|
||||
return principal.with_role(Role.LEARNER)
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="only learners can use sessions")
|
||||
|
||||
|
|
@ -654,15 +655,17 @@ async def _generate_and_save_session_evaluation(sess: InProcSession) -> None:
|
|||
),
|
||||
timeout=min(float(settings.engine_timeout), 45.0),
|
||||
)
|
||||
await session_persistence.save_session_evaluation(
|
||||
saved = await session_persistence.save_session_evaluation(
|
||||
session_persistence.SessionEvaluationWrite.from_result(
|
||||
session_id=sess.session_id,
|
||||
learner_id=sess.learner_id,
|
||||
result=result,
|
||||
)
|
||||
)
|
||||
if saved:
|
||||
await _enqueue_session_review_ready_notification(sess.session_id)
|
||||
except Exception as exc:
|
||||
await session_persistence.save_session_evaluation(
|
||||
saved = await session_persistence.save_session_evaluation(
|
||||
session_persistence.SessionEvaluationWrite.from_error(
|
||||
session_id=sess.session_id,
|
||||
learner_id=sess.learner_id,
|
||||
|
|
@ -671,6 +674,15 @@ async def _generate_and_save_session_evaluation(sess: InProcSession) -> None:
|
|||
error=str(exc),
|
||||
)
|
||||
)
|
||||
if saved:
|
||||
await _enqueue_session_review_ready_notification(sess.session_id)
|
||||
|
||||
|
||||
async def _enqueue_session_review_ready_notification(session_id: str) -> None:
|
||||
try:
|
||||
await notifications.enqueue_session_review_ready(session_id=session_id)
|
||||
except Exception as exc:
|
||||
logger.warning("session review notification enqueue failed: %s", exc)
|
||||
|
||||
|
||||
def _schedule_session_evaluation(sess: InProcSession) -> None:
|
||||
|
|
|
|||
|
|
@ -185,6 +185,7 @@ class NotificationPreferences(BaseModel):
|
|||
session_done: bool = True
|
||||
safety_signal: bool = True
|
||||
learner_progress: bool = False
|
||||
account_approval: bool = True
|
||||
product_news: bool = False
|
||||
|
||||
|
||||
|
|
@ -580,7 +581,7 @@ async def complete_onboarding(
|
|||
|
||||
principal.display_name = updated.display_name
|
||||
principal.profile_completed_at = updated.profile_completed_at
|
||||
if (principal.role == Role.LEARNER or principal.super_admin) and principal.consent_at is None:
|
||||
if principal.can_access_role(Role.LEARNER) and principal.consent_at is None:
|
||||
principal.consent_at = await record_user_consent(principal.user_id)
|
||||
return await _profile_for(principal)
|
||||
|
||||
|
|
|
|||
744
apps/api/app/services/notifications.py
Normal file
744
apps/api/app/services/notifications.py
Normal file
|
|
@ -0,0 +1,744 @@
|
|||
"""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."""
|
||||
|
|
@ -8,7 +8,7 @@ from contextlib import contextmanager
|
|||
from typing import Any
|
||||
from urllib.parse import parse_qs, urlencode, urlsplit
|
||||
|
||||
from fastapi import Response
|
||||
from fastapi import HTTPException, Response
|
||||
from starlette.requests import Request
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
|
|
@ -17,6 +17,7 @@ from .config import Settings, settings
|
|||
from .deps import Principal, Role
|
||||
from .routes import admin as admin_routes
|
||||
from .routes import auth as auth_routes
|
||||
from .routes import sessions as session_routes
|
||||
from .saml import inflate_redirect_request
|
||||
|
||||
|
||||
|
|
@ -355,6 +356,38 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
|
|||
hoonjung_role = auth_routes._role_for_email("hoonjungkoo@hs.ac.kr")
|
||||
self.assertEqual(hoonjung_role, Role.ADMIN)
|
||||
|
||||
async def test_pending_provider_user_enqueues_account_approval_notification(self) -> None:
|
||||
with (
|
||||
patched_settings(
|
||||
environment="dev",
|
||||
auth_new_user_default_status="pending",
|
||||
auth_super_admin_emails=[],
|
||||
auth_admin_emails=[],
|
||||
auth_teacher_emails=[],
|
||||
auth_approved_emails=[],
|
||||
),
|
||||
patch.object(auth_sessions, "get_pool", side_effect=RuntimeError("no db")),
|
||||
patch.object(
|
||||
auth_sessions.notifications,
|
||||
"enqueue_account_pending_approval",
|
||||
AsyncMock(),
|
||||
) as enqueue_approval,
|
||||
):
|
||||
_, user = await auth_sessions.create_session(
|
||||
email="new-approval@hs.ac.kr",
|
||||
display_name="New Approval",
|
||||
role="learner",
|
||||
external_id="google:new-approval",
|
||||
)
|
||||
|
||||
self.assertEqual(user.account_status, "pending")
|
||||
enqueue_approval.assert_awaited_once_with(
|
||||
user_id=user.user_id,
|
||||
email="new-approval@hs.ac.kr",
|
||||
display_name="New Approval",
|
||||
role="learner",
|
||||
)
|
||||
|
||||
async def test_super_admin_can_enter_teacher_and_learner_role_guards(self) -> None:
|
||||
principal = Principal(
|
||||
user_id="00000000-0000-0000-0000-000000000601",
|
||||
|
|
@ -373,6 +406,41 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertTrue(principal.can_access_role(Role.TEACHER))
|
||||
self.assertTrue(principal.can_access_role(Role.ADMIN))
|
||||
|
||||
async def test_admin_role_can_enter_all_role_spaces_without_super_admin(self) -> None:
|
||||
principal = Principal(
|
||||
user_id="00000000-0000-0000-0000-000000000602",
|
||||
role=Role.ADMIN,
|
||||
email="operator@twentyoz.kr",
|
||||
display_name="Operator Admin",
|
||||
admin_access=True,
|
||||
super_admin=False,
|
||||
)
|
||||
|
||||
teacher_checker = deps.require_role(Role.TEACHER)
|
||||
teacher_view = await teacher_checker(principal)
|
||||
learner_view = session_routes._ensure_learner(principal)
|
||||
|
||||
self.assertEqual(teacher_view.role, Role.TEACHER)
|
||||
self.assertEqual(learner_view.role, Role.LEARNER)
|
||||
self.assertTrue(principal.can_access_role(Role.LEARNER))
|
||||
self.assertTrue(principal.can_access_role(Role.TEACHER))
|
||||
self.assertTrue(principal.can_access_role(Role.ADMIN))
|
||||
|
||||
async def test_admin_access_flag_without_admin_role_does_not_enter_learner_space(self) -> None:
|
||||
principal = Principal(
|
||||
user_id="00000000-0000-0000-0000-000000000603",
|
||||
role=Role.TEACHER,
|
||||
email="teacher-admin@hs.ac.kr",
|
||||
display_name="Teacher Admin",
|
||||
admin_access=True,
|
||||
super_admin=False,
|
||||
)
|
||||
|
||||
self.assertTrue(principal.can_access_role(Role.ADMIN))
|
||||
self.assertFalse(principal.can_access_role(Role.LEARNER))
|
||||
with self.assertRaises(HTTPException):
|
||||
session_routes._ensure_learner(principal)
|
||||
|
||||
async def test_only_super_admin_can_grant_admin_access(self) -> None:
|
||||
with (
|
||||
patched_settings(
|
||||
|
|
|
|||
186
apps/api/app/test_notifications.py
Normal file
186
apps/api/app/test_notifications.py
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
"""Notification email rendering and enqueue trigger tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from contextlib import contextmanager
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from .config import settings
|
||||
from .routes import sessions
|
||||
from .services import notifications, state_machine
|
||||
from .services.persona import P1
|
||||
from .store import InProcSession, TurnRecord
|
||||
|
||||
|
||||
@contextmanager
|
||||
def patched_settings(**values: Any):
|
||||
previous = {key: getattr(settings, key) for key in values}
|
||||
for key, value in values.items():
|
||||
setattr(settings, key, value)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
for key, value in previous.items():
|
||||
setattr(settings, key, value)
|
||||
|
||||
|
||||
def _ended_session() -> InProcSession:
|
||||
state = state_machine.init_state(params=P1.openness_params())
|
||||
state.stage = state_machine.Stage.EXPLORE
|
||||
return InProcSession(
|
||||
session_id="00000000-0000-0000-0000-00000000e111",
|
||||
case_id="00000000-0000-0000-0000-00000000e111",
|
||||
learner_id="00000000-0000-0000-0000-000000000111",
|
||||
persona_code=P1.code,
|
||||
theory_mode="humanistic",
|
||||
persona=P1,
|
||||
state=state,
|
||||
session_no=1,
|
||||
created_at=1_000.0,
|
||||
ended_at=1_600.0,
|
||||
ended=True,
|
||||
turns=[
|
||||
TurnRecord(
|
||||
turn_seq=1,
|
||||
speaker="counselor",
|
||||
stage=state.stage.value,
|
||||
text="상담자 발화",
|
||||
text_masked="상담자 발화",
|
||||
),
|
||||
TurnRecord(
|
||||
turn_seq=2,
|
||||
speaker="client",
|
||||
stage=state.stage.value,
|
||||
text="내담자 응답",
|
||||
text_masked="내담자 응답",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class NotificationEmailTemplateTest(unittest.TestCase):
|
||||
def test_session_review_email_uses_polite_copy_and_direct_review_link(self) -> None:
|
||||
rendered = notifications.render_email(
|
||||
"session_review_ready",
|
||||
{
|
||||
"learner_label": "테스트 학습자",
|
||||
"persona_name": "서연",
|
||||
"session_no": 2,
|
||||
"ended_at": "2026-06-29T09:00:00+09:00",
|
||||
"review_url": "https://vignette.chanpaca.net/teach/session/s1/review",
|
||||
"transcript": "메일에 들어가면 안 되는 축어록",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(rendered.subject, "[Vignette] 검토할 회기가 있습니다")
|
||||
self.assertIn("검토할 회기가 있습니다.", rendered.html)
|
||||
self.assertIn("회기 검토하기", rendered.html)
|
||||
self.assertIn("https://vignette.chanpaca.net/teach/session/s1/review", rendered.html)
|
||||
self.assertIn("민감한 회기 내용은 메일에 포함하지 않았습니다", rendered.html)
|
||||
self.assertNotIn("메일에 들어가면 안 되는 축어록", rendered.html)
|
||||
|
||||
def test_account_approval_email_links_admin_users(self) -> None:
|
||||
rendered = notifications.render_email(
|
||||
"account_pending_approval",
|
||||
{
|
||||
"display_name": "신규 사용자",
|
||||
"email": "new@hs.ac.kr",
|
||||
"role": "learner",
|
||||
"approval_url": "https://vignette.chanpaca.net/admin/users",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(rendered.subject, "[Vignette] 새 가입 승인 요청이 있습니다")
|
||||
self.assertIn("새 가입 승인 요청이 있습니다.", rendered.html)
|
||||
self.assertIn("가입 승인 확인하기", rendered.html)
|
||||
self.assertIn("https://vignette.chanpaca.net/admin/users", rendered.html)
|
||||
|
||||
def test_admin_test_email_uses_distinct_test_copy(self) -> None:
|
||||
rendered = notifications.render_email(
|
||||
"admin_test_email",
|
||||
{
|
||||
"requested_by": "admin@hs.ac.kr",
|
||||
"notifications_url": "https://vignette.chanpaca.net/admin",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(rendered.subject, "[Vignette] 메일 알림 테스트입니다")
|
||||
self.assertIn("메일 알림 테스트입니다.", rendered.html)
|
||||
self.assertIn("발송 경로 확인용 테스트", rendered.html)
|
||||
self.assertIn("알림 상태 확인하기", rendered.html)
|
||||
self.assertIn("https://vignette.chanpaca.net/admin", rendered.html)
|
||||
|
||||
def test_smtp_sender_uses_html_text_parts_and_starttls(self) -> None:
|
||||
rendered = notifications.render_email(
|
||||
"session_review_ready",
|
||||
{
|
||||
"learner_label": "테스트 학습자",
|
||||
"persona_name": "서연",
|
||||
"session_no": 1,
|
||||
"review_url": "https://vignette.chanpaca.net/teach/session/s1/review",
|
||||
},
|
||||
)
|
||||
|
||||
with (
|
||||
patched_settings(
|
||||
notification_email_provider="smtp",
|
||||
smtp_host="smtp.example.test",
|
||||
smtp_port=587,
|
||||
smtp_username="mailer",
|
||||
smtp_password="secret",
|
||||
smtp_from_email="no-reply@example.test",
|
||||
smtp_from_name="Vignette",
|
||||
smtp_starttls=True,
|
||||
smtp_ssl=False,
|
||||
),
|
||||
patch.object(notifications.smtplib, "SMTP") as smtp_class,
|
||||
):
|
||||
smtp = smtp_class.return_value.__enter__.return_value
|
||||
provider_id = notifications._send_rendered_email(
|
||||
recipient_email="teacher@hs.ac.kr",
|
||||
recipient_name="교수자",
|
||||
rendered=rendered,
|
||||
)
|
||||
|
||||
self.assertIn("@example.test>", provider_id)
|
||||
smtp_class.assert_called_once_with("smtp.example.test", 587, timeout=15)
|
||||
smtp.starttls.assert_called_once()
|
||||
smtp.login.assert_called_once_with("mailer", "secret")
|
||||
smtp.send_message.assert_called_once()
|
||||
message = smtp.send_message.call_args.args[0]
|
||||
self.assertEqual(message["Subject"], "[Vignette] 검토할 회기가 있습니다")
|
||||
self.assertIn("teacher@hs.ac.kr", message["To"])
|
||||
self.assertTrue(message.is_multipart())
|
||||
|
||||
|
||||
class NotificationTriggerTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_failed_session_evaluation_still_enqueues_teacher_review_notification(self) -> None:
|
||||
sess = _ended_session()
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
sessions.evaluator,
|
||||
"evaluate_session",
|
||||
AsyncMock(side_effect=RuntimeError("engine offline")),
|
||||
),
|
||||
patch.object(
|
||||
sessions.session_persistence,
|
||||
"save_session_evaluation",
|
||||
AsyncMock(return_value=True),
|
||||
) as save_evaluation,
|
||||
patch.object(
|
||||
sessions.notifications,
|
||||
"enqueue_session_review_ready",
|
||||
AsyncMock(),
|
||||
) as enqueue_review,
|
||||
):
|
||||
await sessions._generate_and_save_session_evaluation(sess)
|
||||
|
||||
save_evaluation.assert_awaited_once()
|
||||
enqueue_review.assert_awaited_once_with(session_id=sess.session_id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue