744 lines
27 KiB
Python
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."""
|