Files
vidconf/backend/services/email_templates.py

162 lines
7.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Генератор письма с саммари встречи: plaintext + HTML.
HTML-версия — по мотивам макета `design/mockups/email-summary.html`
(упрощённая структура: шапка/участники/тело саммари/футер, цвета и типографика
светлой темы `design/DESIGN_SYSTEM.md`; почтовые клиенты игнорируют внешние
`<style>`, поэтому все стили — инлайн). Тело саммари приходит от LLM
(`conference_sessions.summary_data`) в фиксированном markdown-подобном
формате промпта `workers/summarizer/prompts/summary_reduce_ru.txt` (заголовки
`## ...`, пункты `- ...`) — при рендере разбирается построчно и оборачивается
в HTML-разметку; заголовок конференции, имена участников и сам текст саммари
экранируются `html.escape`.
Plaintext-альтернатива — обязательный минимум для клиентов без HTML.
"""
from __future__ import annotations
import html
from collections.abc import Sequence
from dataclasses import dataclass
@dataclass(frozen=True)
class SummaryEmailContext:
"""Данные для рендера письма — без привязки к ORM (проще тестировать)."""
conference_title: str
date_label: str
"""Дата встречи в `display_timezone`, формат `ДД.ММ.ГГГГ`."""
time_label: str
"""Время встречи в `display_timezone`, формат `ЧЧ:ММ–ЧЧ:ММ`."""
duration_minutes: int
participant_names: Sequence[str]
summary_text: str
def build_summary_email(context: SummaryEmailContext) -> tuple[str, str]:
"""Собрать (plaintext, html) тело письма с саммари встречи."""
return _build_plaintext(context), _build_html(context)
def _build_plaintext(context: SummaryEmailContext) -> str:
"""Простой текстовый вариант — без экранирования (не HTML)."""
participants = ", ".join(context.participant_names) or "участники не определены"
lines = [
f"Саммари встречи «{context.conference_title}»",
f"{context.date_label}, {context.time_label} ({context.duration_minutes} мин)",
"",
f"Участники: {participants}",
"",
context.summary_text.strip(),
"",
"",
"Письмо сформировано автоматически по итогам конференции в VidConf.",
]
return "\n".join(lines)
def _build_html(context: SummaryEmailContext) -> str:
"""HTML-вариант письма — все пользовательские подстановки экранированы."""
title = html.escape(context.conference_title)
date_label = html.escape(context.date_label)
time_label = html.escape(context.time_label)
participants = html.escape(", ".join(context.participant_names) or "не определены")
body_html = _render_summary_body(context.summary_text)
return f"""\
<!doctype html>
<html lang="ru">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head>
<body style="margin:0; padding:0; background-color:#F1F1F1; font-family:Helvetica, Arial, sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#F1F1F1;">
<tr><td align="center" style="padding: 32px 16px;">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px; max-width:600px; background-color:#FFFFFF; border-radius:24px; overflow:hidden; border:1px solid #E1E3E9;">
<tr>
<td style="padding: 32px 32px 24px 32px; background-color:#F7F7F8;">
<p style="margin: 0 0 6px; font-family:Helvetica, Arial, sans-serif; font-size:12px; font-weight:bold; letter-spacing:.06em; text-transform:uppercase; color:#6976AC;">
Саммари встречи
</p>
<h1 style="margin:0 0 12px; font-family:Helvetica, Arial, sans-serif; font-size:24px; line-height:1.25; font-weight:bold; color:#2E3454;">
{title}
</h1>
<p style="margin:0; font-family:Helvetica, Arial, sans-serif; font-size:14px; color:#535F94;">
{date_label}, {time_label} &middot; {context.duration_minutes} мин
</p>
</td>
</tr>
<tr>
<td style="padding: 24px 32px 8px 32px;">
<p style="margin:0 0 6px; font-family:Helvetica, Arial, sans-serif; font-size:12px; font-weight:bold; letter-spacing:.06em; text-transform:uppercase; color:#A6AECB;">
Участники
</p>
<p style="margin:0; font-family:Helvetica, Arial, sans-serif; font-size:14px; color:#2E3454;">
{participants}
</p>
</td>
</tr>
<tr><td style="padding: 16px 32px;"><hr style="border:none; border-top:1px solid #E1E3E9; margin:0;"></td></tr>
<tr>
<td style="padding: 8px 32px 24px 32px;">
{body_html}
</td>
</tr>
<tr>
<td style="padding: 20px 32px 32px 32px; background-color:#F7F7F8; border-top:1px solid #E1E3E9;">
<p style="margin:0; font-family:Helvetica, Arial, sans-serif; font-size:12px; line-height:1.6; color:#A6AECB;">
Письмо сформировано автоматически по итогам конференции в VidConf (self-hosted).
</p>
</td>
</tr>
</table>
</td></tr>
</table>
</body>
</html>
"""
def _render_summary_body(summary_text: str) -> str:
"""Разобрать markdown-подобный текст саммари (`## заголовок`, `- пункт`) в HTML.
Формат фиксирован промптом суммаризации; при
отклонении LLM от формата непонятые строки рендерятся как обычные
абзацы — разбор не должен падать на неожиданном вводе. Всё содержимое
экранируется `html.escape` (текст саммари — от LLM, потенциальная
инъекция в HTML-письмо).
"""
parts: list[str] = []
in_list = False
for raw_line in summary_text.strip("\n").splitlines():
line = raw_line.strip()
if not line:
continue
if line.startswith("## "):
if in_list:
parts.append("</ul>")
in_list = False
heading = html.escape(line[3:].strip())
parts.append(
'<p style="margin:16px 0 8px; font-family:Helvetica, Arial, sans-serif; '
'font-size:15px; font-weight:bold; color:#2E3454;">' + heading + "</p>"
)
elif line.startswith("- "):
if not in_list:
parts.append('<ul style="margin:0 0 8px; padding-left:20px;">')
in_list = True
item = html.escape(line[2:].strip())
parts.append(
'<li style="font-family:Helvetica, Arial, sans-serif; font-size:14px; '
'line-height:1.6; color:#2E3454; padding:2px 0;">' + item + "</li>"
)
else:
if in_list:
parts.append("</ul>")
in_list = False
parts.append(
'<p style="margin:0 0 8px; font-family:Helvetica, Arial, sans-serif; '
'font-size:14px; line-height:1.6; color:#2E3454;">' + html.escape(line) + "</p>"
)
if in_list:
parts.append("</ul>")
return "\n ".join(parts)