메일 알림 시스템 추가
This commit is contained in:
parent
ddf12a851c
commit
3bf38c50df
22 changed files with 1769 additions and 31 deletions
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."""
|
||||
Loading…
Add table
Add a link
Reference in a new issue