chore: Added SMTP config, User notification_preferences JSONB, EmailDig…
- "backend/config.py" - "backend/models.py" - "backend/schemas.py" - "backend/services/email.py" - "alembic/versions/029_add_email_digest.py" GSD-Task: S01/T01
This commit is contained in:
parent
ecfdc76ba6
commit
067d7ed332
5 changed files with 256 additions and 0 deletions
48
alembic/versions/029_add_email_digest.py
Normal file
48
alembic/versions/029_add_email_digest.py
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
"""Add notification_preferences to users and email_digest_log table.
|
||||||
|
|
||||||
|
Revision ID: 029_add_email_digest
|
||||||
|
Revises: 028_add_shorts_template
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "029_add_email_digest"
|
||||||
|
down_revision = "028_add_shorts_template"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# notification_preferences JSONB on users
|
||||||
|
op.add_column(
|
||||||
|
"users",
|
||||||
|
sa.Column(
|
||||||
|
"notification_preferences",
|
||||||
|
JSONB,
|
||||||
|
nullable=False,
|
||||||
|
server_default='{"email_digests": true, "digest_frequency": "daily"}',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# email_digest_log table
|
||||||
|
op.create_table(
|
||||||
|
"email_digest_log",
|
||||||
|
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.func.gen_random_uuid()),
|
||||||
|
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("digest_sent_at", sa.DateTime, server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("content_summary", JSONB, nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_email_digest_log_user_sent",
|
||||||
|
"email_digest_log",
|
||||||
|
["user_id", "digest_sent_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_email_digest_log_user_sent", table_name="email_digest_log")
|
||||||
|
op.drop_table("email_digest_log")
|
||||||
|
op.drop_column("users", "notification_preferences")
|
||||||
|
|
@ -83,6 +83,14 @@ class Settings(BaseSettings):
|
||||||
video_metadata_path: str = "/data/video_meta"
|
video_metadata_path: str = "/data/video_meta"
|
||||||
video_source_path: str = "/videos"
|
video_source_path: str = "/videos"
|
||||||
|
|
||||||
|
# SMTP (email digests)
|
||||||
|
smtp_host: str = ""
|
||||||
|
smtp_port: int = 587
|
||||||
|
smtp_user: str = ""
|
||||||
|
smtp_password: str = ""
|
||||||
|
smtp_from_address: str = ""
|
||||||
|
smtp_tls: bool = True
|
||||||
|
|
||||||
# Git commit SHA (set at Docker build time or via env var)
|
# Git commit SHA (set at Docker build time or via env var)
|
||||||
git_commit_sha: str = "unknown"
|
git_commit_sha: str = "unknown"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ from sqlalchemy import (
|
||||||
Enum,
|
Enum,
|
||||||
Float,
|
Float,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
|
Index,
|
||||||
Integer,
|
Integer,
|
||||||
String,
|
String,
|
||||||
Text,
|
Text,
|
||||||
|
|
@ -168,6 +169,10 @@ class User(Base):
|
||||||
is_active: Mapped[bool] = mapped_column(
|
is_active: Mapped[bool] = mapped_column(
|
||||||
Boolean, default=True, server_default="true"
|
Boolean, default=True, server_default="true"
|
||||||
)
|
)
|
||||||
|
notification_preferences: Mapped[dict] = mapped_column(
|
||||||
|
JSONB, nullable=False,
|
||||||
|
server_default='{"email_digests": true, "digest_frequency": "daily"}',
|
||||||
|
)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
default=_now, server_default=func.now()
|
default=_now, server_default=func.now()
|
||||||
)
|
)
|
||||||
|
|
@ -179,6 +184,26 @@ class User(Base):
|
||||||
creator: Mapped[Creator | None] = sa_relationship()
|
creator: Mapped[Creator | None] = sa_relationship()
|
||||||
|
|
||||||
|
|
||||||
|
class EmailDigestLog(Base):
|
||||||
|
"""Record of a digest email sent to a user."""
|
||||||
|
__tablename__ = "email_digest_log"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_email_digest_log_user_sent", "user_id", "digest_sent_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = _uuid_pk()
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), nullable=False,
|
||||||
|
)
|
||||||
|
digest_sent_at: Mapped[datetime] = mapped_column(
|
||||||
|
default=_now, server_default=func.now()
|
||||||
|
)
|
||||||
|
content_summary: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
|
||||||
|
# relationships
|
||||||
|
user: Mapped[User] = sa_relationship()
|
||||||
|
|
||||||
|
|
||||||
class InviteCode(Base):
|
class InviteCode(Base):
|
||||||
"""Single-use or limited-use invite codes for registration gating."""
|
"""Single-use or limited-use invite codes for registration gating."""
|
||||||
__tablename__ = "invite_codes"
|
__tablename__ = "invite_codes"
|
||||||
|
|
|
||||||
|
|
@ -847,3 +847,17 @@ class ShortsTemplateUpdate(BaseModel):
|
||||||
outro_duration_secs: float = Field(default=2.0, ge=1.0, le=5.0)
|
outro_duration_secs: float = Field(default=2.0, ge=1.0, le=5.0)
|
||||||
show_intro: bool = False
|
show_intro: bool = False
|
||||||
show_outro: bool = False
|
show_outro: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
# ── Notification Preferences ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class NotificationPreferences(BaseModel):
|
||||||
|
"""Current notification preferences for a user."""
|
||||||
|
email_digests: bool = True
|
||||||
|
digest_frequency: str = "daily"
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationPreferencesUpdate(BaseModel):
|
||||||
|
"""Partial update for notification preferences."""
|
||||||
|
email_digests: bool | None = None
|
||||||
|
digest_frequency: str | None = None
|
||||||
|
|
|
||||||
161
backend/services/email.py
Normal file
161
backend/services/email.py
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
"""Email digest composition and SMTP delivery.
|
||||||
|
|
||||||
|
Provides three public functions:
|
||||||
|
- is_smtp_configured(settings) — gate check before attempting sends
|
||||||
|
- compose_digest_html(user_display_name, creator_content_groups, unsubscribe_url)
|
||||||
|
- send_email(to_address, subject, html_body, settings) — delivers via smtplib
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import smtplib
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from html import escape
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from config import Settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ── Public API ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def is_smtp_configured(settings: Settings) -> bool:
|
||||||
|
"""Return True only if SMTP host and from-address are set."""
|
||||||
|
return bool(settings.smtp_host) and bool(settings.smtp_from_address)
|
||||||
|
|
||||||
|
|
||||||
|
def compose_digest_html(
|
||||||
|
user_display_name: str,
|
||||||
|
creator_content_groups: list[dict],
|
||||||
|
unsubscribe_url: str,
|
||||||
|
) -> str:
|
||||||
|
"""Build an HTML email body for the digest.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_display_name: Recipient's display name.
|
||||||
|
creator_content_groups: List of dicts, each with:
|
||||||
|
- creator_name (str)
|
||||||
|
- posts (list of {title, url})
|
||||||
|
- technique_pages (list of {title, url})
|
||||||
|
unsubscribe_url: Signed link to toggle off digests.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HTML string ready for MIMEText.
|
||||||
|
"""
|
||||||
|
if not creator_content_groups:
|
||||||
|
return _minimal_html(user_display_name, unsubscribe_url)
|
||||||
|
|
||||||
|
sections: list[str] = []
|
||||||
|
for group in creator_content_groups:
|
||||||
|
creator_name = escape(group.get("creator_name", "Unknown"))
|
||||||
|
items_html = ""
|
||||||
|
|
||||||
|
for post in group.get("posts", []):
|
||||||
|
title = escape(post.get("title", "Untitled"))
|
||||||
|
url = escape(post.get("url", "#"))
|
||||||
|
items_html += (
|
||||||
|
f'<li style="margin-bottom:6px;">'
|
||||||
|
f'<a href="{url}" style="color:#3b82f6;text-decoration:none;">{title}</a>'
|
||||||
|
f' <span style="color:#9ca3af;font-size:12px;">(post)</span></li>'
|
||||||
|
)
|
||||||
|
|
||||||
|
for page in group.get("technique_pages", []):
|
||||||
|
title = escape(page.get("title", "Untitled"))
|
||||||
|
url = escape(page.get("url", "#"))
|
||||||
|
items_html += (
|
||||||
|
f'<li style="margin-bottom:6px;">'
|
||||||
|
f'<a href="{url}" style="color:#3b82f6;text-decoration:none;">{title}</a>'
|
||||||
|
f' <span style="color:#9ca3af;font-size:12px;">(technique)</span></li>'
|
||||||
|
)
|
||||||
|
|
||||||
|
if items_html:
|
||||||
|
sections.append(
|
||||||
|
f'<h3 style="margin:16px 0 8px;font-size:16px;color:#f1f5f9;">{creator_name}</h3>'
|
||||||
|
f'<ul style="list-style:none;padding:0;margin:0;">{items_html}</ul>'
|
||||||
|
)
|
||||||
|
|
||||||
|
body_content = "\n".join(sections) if sections else "<p>No new content this period.</p>"
|
||||||
|
|
||||||
|
return _wrap_html(user_display_name, body_content, unsubscribe_url)
|
||||||
|
|
||||||
|
|
||||||
|
def send_email(
|
||||||
|
to_address: str,
|
||||||
|
subject: str,
|
||||||
|
html_body: str,
|
||||||
|
settings: Settings,
|
||||||
|
) -> bool:
|
||||||
|
"""Send an HTML email via SMTP.
|
||||||
|
|
||||||
|
Returns True on success, False on any SMTP error (logged).
|
||||||
|
Uses a 10-second connection timeout.
|
||||||
|
"""
|
||||||
|
if not is_smtp_configured(settings):
|
||||||
|
logger.warning("send_email called but SMTP is not configured")
|
||||||
|
return False
|
||||||
|
|
||||||
|
msg = MIMEMultipart("alternative")
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg["From"] = settings.smtp_from_address
|
||||||
|
msg["To"] = to_address
|
||||||
|
msg.attach(MIMEText(html_body, "html", "utf-8"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
if settings.smtp_tls:
|
||||||
|
server = smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=10)
|
||||||
|
server.starttls()
|
||||||
|
else:
|
||||||
|
server = smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=10)
|
||||||
|
|
||||||
|
if settings.smtp_user:
|
||||||
|
server.login(settings.smtp_user, settings.smtp_password)
|
||||||
|
|
||||||
|
server.sendmail(settings.smtp_from_address, to_address, msg.as_string())
|
||||||
|
server.quit()
|
||||||
|
logger.info("Digest email sent to %s", to_address)
|
||||||
|
return True
|
||||||
|
except smtplib.SMTPException:
|
||||||
|
logger.exception("SMTP error sending digest to %s", to_address)
|
||||||
|
return False
|
||||||
|
except OSError:
|
||||||
|
logger.exception("Network error connecting to SMTP server for %s", to_address)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ── Private helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _minimal_html(user_display_name: str, unsubscribe_url: str) -> str:
|
||||||
|
"""Fallback HTML when there's no new content."""
|
||||||
|
return _wrap_html(
|
||||||
|
user_display_name,
|
||||||
|
'<p style="color:#94a3b8;">No new content from your followed creators this period.</p>',
|
||||||
|
unsubscribe_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _wrap_html(user_display_name: str, body_content: str, unsubscribe_url: str) -> str:
|
||||||
|
"""Wrap body content in the standard digest email shell."""
|
||||||
|
name = escape(user_display_name)
|
||||||
|
unsub = escape(unsubscribe_url)
|
||||||
|
return f"""\
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><meta charset="utf-8"></head>
|
||||||
|
<body style="margin:0;padding:0;background:#0f172a;font-family:system-ui,-apple-system,sans-serif;">
|
||||||
|
<div style="max-width:600px;margin:0 auto;padding:24px;">
|
||||||
|
<h1 style="font-size:22px;color:#e2e8f0;margin-bottom:4px;">Chrysopedia Digest</h1>
|
||||||
|
<p style="color:#94a3b8;margin-top:0;">Hi {name}, here's what's new from creators you follow.</p>
|
||||||
|
<hr style="border:none;border-top:1px solid #1e293b;margin:16px 0;">
|
||||||
|
{body_content}
|
||||||
|
<hr style="border:none;border-top:1px solid #1e293b;margin:24px 0 12px;">
|
||||||
|
<p style="font-size:12px;color:#64748b;text-align:center;">
|
||||||
|
<a href="{unsub}" style="color:#64748b;text-decoration:underline;">Unsubscribe from digests</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
Loading…
Add table
Reference in a new issue