diff --git a/alembic/versions/029_add_email_digest.py b/alembic/versions/029_add_email_digest.py new file mode 100644 index 0000000..66d0bc9 --- /dev/null +++ b/alembic/versions/029_add_email_digest.py @@ -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") diff --git a/backend/config.py b/backend/config.py index 5336275..aad490c 100644 --- a/backend/config.py +++ b/backend/config.py @@ -83,6 +83,14 @@ class Settings(BaseSettings): video_metadata_path: str = "/data/video_meta" 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: str = "unknown" diff --git a/backend/models.py b/backend/models.py index 6c8191b..4c6ffc1 100644 --- a/backend/models.py +++ b/backend/models.py @@ -17,6 +17,7 @@ from sqlalchemy import ( Enum, Float, ForeignKey, + Index, Integer, String, Text, @@ -168,6 +169,10 @@ class User(Base): is_active: Mapped[bool] = mapped_column( 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( default=_now, server_default=func.now() ) @@ -179,6 +184,26 @@ class User(Base): 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): """Single-use or limited-use invite codes for registration gating.""" __tablename__ = "invite_codes" diff --git a/backend/schemas.py b/backend/schemas.py index 84b1282..b4a57e2 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -847,3 +847,17 @@ class ShortsTemplateUpdate(BaseModel): outro_duration_secs: float = Field(default=2.0, ge=1.0, le=5.0) show_intro: 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 diff --git a/backend/services/email.py b/backend/services/email.py new file mode 100644 index 0000000..941bc2d --- /dev/null +++ b/backend/services/email.py @@ -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'
No new content this period.
" + + 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, + 'No new content from your followed creators this period.
', + 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"""\ + + + + +Hi {name}, here's what's new from creators you follow.
+