Первоначальная версия VidConf
This commit is contained in:
161
backend/services/email_templates.py
Normal file
161
backend/services/email_templates.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""Генератор письма с саммари встречи: 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} · {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)
|
||||
Reference in New Issue
Block a user