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:
jlightner 2026-04-04 12:11:13 +00:00
parent 0f9e76babd
commit 34a45d1c8e
15 changed files with 17630 additions and 23 deletions

View file

@ -0,0 +1 @@
[]

View file

@ -1,6 +1,160 @@
# S01: [A] Notification System (Email Digests)
**Goal:** Build email notification system for follower post alerts
**Goal:** Followers receive batched email digests when followed creators publish new content (posts or technique pages). Digest job runs on a configurable schedule via Celery Beat. Users can toggle digest preferences and unsubscribe via signed link.
**Demo:** After this: Followers receive email digests when followed creators post new content
## Tasks
- [x] **T01: Added SMTP config, User notification_preferences JSONB, EmailDigestLog model, migration 029, email digest composer+sender, and notification preference schemas** — Foundation task: add SMTP settings to config.py, add notification_preferences JSONB column to User model, create EmailDigestLog model, write Alembic migration 029, and implement the email service module (HTML digest composer + smtplib sender).
## Steps
1. Add SMTP fields to `backend/config.py` Settings class: `smtp_host` (str, default ''), `smtp_port` (int, default 587), `smtp_user` (str, default ''), `smtp_password` (str, default ''), `smtp_from_address` (str, default ''), `smtp_tls` (bool, default True). All default to empty/safe values so the app starts without SMTP configured.
2. Add `notification_preferences` column to User model in `backend/models.py`: `mapped_column(JSONB, nullable=False, server_default='{"email_digests": true, "digest_frequency": "daily"}')`. This stores per-user prefs as a JSONB dict.
3. Create `EmailDigestLog` model in `backend/models.py`:
- `id` (UUID PK)
- `user_id` (FK to users.id, CASCADE)
- `digest_sent_at` (datetime, default _now)
- `content_summary` (JSONB — list of {creator_id, post_ids, technique_page_ids} included in this digest)
- Index on `(user_id, digest_sent_at)` for efficient last-sent queries
4. Create Alembic migration `alembic/versions/029_add_email_digest.py`: add `notification_preferences` column to users table, create `email_digest_log` table.
5. Create `backend/services/email.py`:
- `compose_digest_html(user_display_name, creator_content_groups, unsubscribe_url)` — returns HTML string. Groups content by creator, lists new posts and technique pages with titles and links. Simple inline-CSS HTML template (no external template engine).
- `send_email(to_address, subject, html_body, settings)` — uses `smtplib.SMTP`/`SMTP_SSL` based on `smtp_tls`. Returns bool (success/failure). Catches `smtplib.SMTPException` and logs error. 10-second timeout per connection.
- `is_smtp_configured(settings)` — returns True only if smtp_host and smtp_from_address are non-empty.
6. Add Pydantic response/request schemas to `backend/schemas.py`: `NotificationPreferences` (email_digests: bool, digest_frequency: str), `NotificationPreferencesUpdate` (same fields, optional).
## Failure Modes
| Dependency | On error | On timeout | On malformed response |
|------------|----------|-----------|----------------------|
| SMTP server | Log error, return False from send_email | 10s socket timeout, log + return False | N/A (we compose, not receive) |
| PostgreSQL (migration) | Migration fails, rollback | Standard Alembic timeout | N/A |
## Negative Tests
- `is_smtp_configured` returns False when host or from_address is empty
- `send_email` returns False and logs on SMTP connection failure
- `compose_digest_html` handles empty content groups (returns minimal "no new content" message)
## Must-Haves
- [ ] SMTP settings in config.py with safe defaults (empty = unconfigured)
- [ ] notification_preferences JSONB on User with server_default
- [ ] EmailDigestLog model with user_id + digest_sent_at index
- [ ] Alembic migration 029 applies cleanly
- [ ] Email service composes valid HTML and sends via smtplib
- [ ] Schemas for notification preferences
- Estimate: 1.5h
- Files: backend/config.py, backend/models.py, backend/schemas.py, backend/services/email.py, alembic/versions/029_add_email_digest.py
- Verify: cd backend && python -c "from models import EmailDigestLog, User; from services.email import compose_digest_html, send_email, is_smtp_configured; from config import get_settings; s = get_settings(); assert not is_smtp_configured(s); print('OK')" && cd ../alembic && python -c "import importlib.util; spec = importlib.util.spec_from_file_location('m', 'versions/029_add_email_digest.py'); mod = importlib.util.module_from_spec(spec); print('Migration module loads OK')"
- [ ] **T02: Digest Celery task with Beat scheduling and Docker config** — Build the digest orchestration: a Celery task that queries new content since each user's last digest, groups by followed creator, composes and sends emails via the email service, and logs successful sends. Configure Celery Beat to run it daily. Update docker-compose.yml to add --beat flag to the worker.
## Steps
1. Create `backend/tasks/notifications.py` with `send_digest_emails` Celery task:
- Import celery_app from worker module
- Use sync SQLAlchemy engine (same pattern as pipeline/stages.py — create sync engine from DATABASE_URL converted to postgresql://)
- Query all users where `notification_preferences->>'email_digests'` is true
- For each user, find their last EmailDigestLog entry (max digest_sent_at)
- Query CreatorFollow to get followed creator_ids
- For each followed creator: query Posts where `is_published=True AND created_at > last_digest_at`, query TechniquePages where `created_at > last_digest_at` (join through TechniquePageVideo → SourceVideo to match creator_id)
- Skip user if no new content across all followed creators
- Call `compose_digest_html` with grouped content, generate signed unsubscribe URL
- Call `send_email` — on success, INSERT EmailDigestLog with content_summary JSONB
- If `is_smtp_configured()` returns False, log warning and return early (graceful no-op)
- Log structured messages: task start, per-user send result, task complete with count
2. Add Celery Beat schedule to `backend/worker.py`:
```python
celery_app.conf.beat_schedule = {
'send-digest-emails': {
'task': 'tasks.notifications.send_digest_emails',
'schedule': crontab(hour=9, minute=0), # daily at 9am UTC
},
}
```
Import `from celery.schedules import crontab` at top.
3. Import `tasks.notifications` in worker.py alongside the existing `pipeline.stages` import so the task decorator registers.
4. Update `docker-compose.yml` worker command from `["celery", "-A", "worker", "worker", "--loglevel=info", "--concurrency=1"]` to `["celery", "-A", "worker", "worker", "--beat", "--loglevel=info", "--concurrency=1"]`.
5. Generate signed unsubscribe tokens: use `itsdangerous.URLSafeTimedSerializer` with `settings.app_secret_key`. Token encodes user_id. Unsubscribe URL format: `{base_url}/api/v1/notifications/unsubscribe?token={token}`. Token valid for 30 days.
## Failure Modes
| Dependency | On error | On timeout | On malformed response |
|------------|----------|-----------|----------------------|
| SMTP (via email service) | Log error per user, continue to next user | 10s timeout in email service | N/A |
| PostgreSQL | Task fails with exception, Celery retries | Standard SQLAlchemy timeout | N/A |
| Redis (Celery broker) | Task not dispatched, Beat retries next schedule | Celery handles reconnection | N/A |
## Load Profile
- Shared resources: sync DB session (one per task run), SMTP connection (one per email)
- Per-operation cost: ~3 DB queries per user (last digest, follows, content), 1 SMTP send per user
- 10x breakpoint: N/A — <50 users, volume is trivial. At 500+ users, batch SMTP connections.
## Must-Haves
- [ ] Digest task queries correct new content per followed creator
- [ ] Graceful no-op when SMTP is unconfigured
- [ ] EmailDigestLog written only on successful send (deduplication)
- [ ] Celery Beat schedule configured for daily run
- [ ] Docker worker command includes --beat flag
- [ ] Signed unsubscribe URL generated per email
- Estimate: 1.5h
- Files: backend/tasks/__init__.py, backend/tasks/notifications.py, backend/worker.py, docker-compose.yml
- Verify: cd backend && python -c "from tasks.notifications import send_digest_emails; print('Task imports OK')" && python -c "from worker import celery_app; assert 'send-digest-emails' in celery_app.conf.beat_schedule; print('Beat schedule OK')" && grep -q '\-\-beat' ../docker-compose.yml && echo 'Docker beat flag OK'
- [ ] **T03: Notification preferences API, unsubscribe endpoint, frontend settings, and integration test** — Wire the user-facing surfaces: API endpoints for reading/updating notification preferences, a public unsubscribe endpoint (no auth required, uses signed token), a frontend settings toggle in the creator settings page, and an integration test that verifies the full digest flow with mocked SMTP.
## Steps
1. Create `backend/routers/notifications.py`:
- `GET /notifications/preferences` — returns current user's notification_preferences JSONB. Requires auth.
- `PUT /notifications/preferences` — updates notification_preferences. Validates against NotificationPreferencesUpdate schema. Requires auth.
- `GET /notifications/unsubscribe` — accepts `token` query param. Decodes with `itsdangerous.URLSafeTimedSerializer` (max_age=30 days). Sets `notification_preferences.email_digests = False` for the user. Returns simple HTML page confirming unsubscription. No auth required.
2. Mount the router in `backend/main.py`: `app.include_router(notifications.router, prefix="/api/v1")`
3. Create `frontend/src/api/notifications.ts`:
- `getNotificationPreferences()` — GET /api/v1/notifications/preferences
- `updateNotificationPreferences(prefs)` — PUT /api/v1/notifications/preferences
4. Add notification settings section to `frontend/src/pages/CreatorSettings.tsx`:
- New section "Email Notifications" with a toggle switch for email digests (on/off)
- Frequency selector (daily/weekly) shown only when digests are enabled
- Fetch current prefs on mount, PUT on change with optimistic UI update
- Use existing form/toggle patterns from the page
5. Create integration test `backend/tests/test_notifications.py`:
- Test GET/PUT preferences endpoints (auth required, returns correct shape)
- Test unsubscribe endpoint with valid and expired tokens
- Test digest task end-to-end: create test user, creator, follow, published post, mock SMTP, run `send_digest_emails`, assert email was "sent" (mock called with correct args), assert EmailDigestLog created
- Test digest task skips when no new content
- Test digest task no-op when SMTP unconfigured
6. Add `itsdangerous` to `requirements.txt` if not already present.
## Negative Tests
- PUT preferences with invalid digest_frequency value → 422
- Unsubscribe with expired token (>30 days) → error page
- Unsubscribe with tampered token → error page
- GET/PUT preferences without auth → 401
## Must-Haves
- [ ] GET/PUT notification preferences endpoints with auth
- [ ] Unsubscribe endpoint works without auth via signed token
- [ ] Frontend toggle for email digests in creator settings
- [ ] Integration test covers digest task happy path with mocked SMTP
- [ ] Integration test covers unsubscribe with valid and invalid tokens
- Estimate: 2h
- Files: backend/routers/notifications.py, backend/main.py, backend/tests/test_notifications.py, frontend/src/api/notifications.ts, frontend/src/pages/CreatorSettings.tsx, requirements.txt
- Verify: cd backend && python -m pytest tests/test_notifications.py -v --timeout=30 2>&1 | tail -20

View file

@ -0,0 +1,80 @@
# S01 Research — Notification System (Email Digests)
## Summary
Build an email digest system: when a creator publishes new content (posts, technique pages), followers receive batched email notifications. No email infrastructure exists yet — this is greenfield. The follow system (CreatorFollow model, CRUD endpoints, frontend API) is fully built and working.
## Recommendation
Use Python stdlib `smtplib`/`email.mime` for sending, Celery Beat for scheduling, and a new `email_notification_log` table for deduplication. Add a `notification_preferences` JSONB column to the User model. No external email library needed — the volume is tiny (single-admin, invite-only tool with <50 users).
SMTP credentials come from environment variables. The digest job runs as a periodic Celery Beat task (daily or configurable). Each run: query new content since last digest per creator, find followers, compose HTML email, send, log.
## Implementation Landscape
### What exists
| Component | Location | Status |
|---|---|---|
| CreatorFollow model | `backend/models.py:748` | Complete — user_id, creator_id, created_at, unique constraint |
| Follow CRUD endpoints | `backend/routers/follows.py` | Complete — follow/unfollow/status/list |
| Follow frontend API | `frontend/src/api/follows.ts` | Complete — all 4 functions |
| User model with email | `backend/models.py:152` | Has `email` field (String 255, unique, not null) |
| Post model | `backend/models.py:773` | Has `is_published`, `created_at`, `creator_id` |
| TechniquePage model | `backend/models.py:291` | Has `created_at`, `updated_at`, creator via SourceVideo join |
| Celery worker | `docker-compose.yml:143`, `backend/worker.py` | Running, concurrency=1, no Beat scheduler |
| Redis | `docker-compose.yml` | Running, used as Celery broker |
| Config/Settings | `backend/config.py` | Pydantic BaseSettings, no SMTP config yet |
| Alembic migrations | `alembic/versions/` | 28 migrations, latest is `028_add_shorts_template.py` |
### What needs building
1. **SMTP config** — Add `smtp_host`, `smtp_port`, `smtp_user`, `smtp_password`, `smtp_from_address`, `smtp_tls` to `Settings` in `config.py`
2. **Notification preferences** — Add `notification_preferences` JSONB column to User model (email_digests: bool, digest_frequency: "daily"|"weekly") + Alembic migration
3. **Email notification log** — New `EmailDigestLog` model tracking (user_id, digest_sent_at, content_ids_included) for deduplication
4. **Email service**`backend/services/email.py` using stdlib `smtplib` + `email.mime` for composing and sending HTML digest emails
5. **Digest task** — Celery task in `backend/pipeline/stages.py` or a new `backend/tasks/notifications.py` that queries new content, groups by creator, sends digests
6. **Celery Beat schedule** — Add Beat config to `worker.py` and a Beat container/process to docker-compose
7. **Notification preferences API** — Endpoints to get/update user notification preferences
8. **Frontend notification settings** — Small UI for users to toggle digest emails on/off
9. **Unsubscribe link** — One-click unsubscribe in digest email (signed URL or token)
### Natural seams for task decomposition
1. **DB + Config foundation** (migration, model changes, SMTP config) — unblocks everything
2. **Email service** (compose + send logic, template) — independent, unit-testable
3. **Digest Celery task + Beat setup** (orchestration, scheduling, Docker changes) — depends on 1+2
4. **API + Frontend** (notification preferences endpoints, settings UI, unsubscribe) — depends on 1
5. **Integration verification** (end-to-end test with real SMTP or test stub)
### Key design decisions needed
**Content triggers:** Two types of "new content" exist:
- **Posts:** `Post.is_published == True` with `created_at > last_digest_sent_at`. Direct `creator_id` on the model.
- **Technique pages:** `TechniquePage.created_at > last_digest_sent_at`. Creator linkage requires join through `TechniquePageVideo → SourceVideo → Creator`. New technique pages come from pipeline completion (stage 5 sets `processing_status = complete`).
**Digest grouping:** Group by creator — "Creator X published 2 new articles and 1 post since your last digest." Each follower gets one email per digest run, with all followed creators' new content summarized.
**Celery Beat deployment:** Two options:
- Add `--beat` flag to existing worker command: `celery -A worker worker --beat --loglevel=info` (simpler, fine for single-worker)
- Separate Beat container (safer for multi-worker, but overkill here)
Recommendation: `--beat` on existing worker since concurrency=1 already.
**SMTP provider:** Environment variables, no provider locked in. For ub01 self-hosted, could use any external SMTP relay (Mailgun, SES, or even local Postfix). The code just needs `smtplib.SMTP(host, port)`.
### Risks
| Risk | Mitigation |
|---|---|
| No SMTP server configured at deploy time | Make digest task no-op when SMTP settings are empty. Log warning, don't crash. |
| Email sending blocks Celery worker | Send emails in the digest task with reasonable timeouts (10s per email). Volume is <50 emails per run. |
| TechniquePage→Creator join is complex | Pre-query: get creator_ids with new technique pages via `TechniquePageVideo → SourceVideo.creator_id` join, then match against CreatorFollow |
| User has no email verification | Accept this — invite-only system, emails are entered at registration. Add verification later if needed. |
### Verification approach
- **Unit tests:** Email composition (HTML template renders correctly), digest query (correct content grouped by creator), deduplication (no duplicate sends)
- **Integration test:** Mock SMTP server, trigger digest task, verify emails sent to correct followers with correct content
- **Docker verification:** Celery Beat schedule fires, task runs without error in logs
- **Manual:** Configure SMTP, follow a creator, publish content, wait for digest or trigger manually, check inbox

View file

@ -0,0 +1,71 @@
---
estimated_steps: 32
estimated_files: 5
skills_used: []
---
# T01: DB models, migration, SMTP config, and email service
Foundation task: add SMTP settings to config.py, add notification_preferences JSONB column to User model, create EmailDigestLog model, write Alembic migration 029, and implement the email service module (HTML digest composer + smtplib sender).
## Steps
1. Add SMTP fields to `backend/config.py` Settings class: `smtp_host` (str, default ''), `smtp_port` (int, default 587), `smtp_user` (str, default ''), `smtp_password` (str, default ''), `smtp_from_address` (str, default ''), `smtp_tls` (bool, default True). All default to empty/safe values so the app starts without SMTP configured.
2. Add `notification_preferences` column to User model in `backend/models.py`: `mapped_column(JSONB, nullable=False, server_default='{"email_digests": true, "digest_frequency": "daily"}')`. This stores per-user prefs as a JSONB dict.
3. Create `EmailDigestLog` model in `backend/models.py`:
- `id` (UUID PK)
- `user_id` (FK to users.id, CASCADE)
- `digest_sent_at` (datetime, default _now)
- `content_summary` (JSONB — list of {creator_id, post_ids, technique_page_ids} included in this digest)
- Index on `(user_id, digest_sent_at)` for efficient last-sent queries
4. Create Alembic migration `alembic/versions/029_add_email_digest.py`: add `notification_preferences` column to users table, create `email_digest_log` table.
5. Create `backend/services/email.py`:
- `compose_digest_html(user_display_name, creator_content_groups, unsubscribe_url)` — returns HTML string. Groups content by creator, lists new posts and technique pages with titles and links. Simple inline-CSS HTML template (no external template engine).
- `send_email(to_address, subject, html_body, settings)` — uses `smtplib.SMTP`/`SMTP_SSL` based on `smtp_tls`. Returns bool (success/failure). Catches `smtplib.SMTPException` and logs error. 10-second timeout per connection.
- `is_smtp_configured(settings)` — returns True only if smtp_host and smtp_from_address are non-empty.
6. Add Pydantic response/request schemas to `backend/schemas.py`: `NotificationPreferences` (email_digests: bool, digest_frequency: str), `NotificationPreferencesUpdate` (same fields, optional).
## Failure Modes
| Dependency | On error | On timeout | On malformed response |
|------------|----------|-----------|----------------------|
| SMTP server | Log error, return False from send_email | 10s socket timeout, log + return False | N/A (we compose, not receive) |
| PostgreSQL (migration) | Migration fails, rollback | Standard Alembic timeout | N/A |
## Negative Tests
- `is_smtp_configured` returns False when host or from_address is empty
- `send_email` returns False and logs on SMTP connection failure
- `compose_digest_html` handles empty content groups (returns minimal "no new content" message)
## Must-Haves
- [ ] SMTP settings in config.py with safe defaults (empty = unconfigured)
- [ ] notification_preferences JSONB on User with server_default
- [ ] EmailDigestLog model with user_id + digest_sent_at index
- [ ] Alembic migration 029 applies cleanly
- [ ] Email service composes valid HTML and sends via smtplib
- [ ] Schemas for notification preferences
## Inputs
- ``backend/config.py` — existing Settings class to extend with SMTP fields`
- ``backend/models.py` — existing User and base model patterns to follow`
- ``backend/schemas.py` — existing Pydantic schema patterns`
## Expected Output
- ``backend/config.py` — Settings class with SMTP fields added`
- ``backend/models.py` — User.notification_preferences column + EmailDigestLog model added`
- ``backend/schemas.py` — NotificationPreferences and NotificationPreferencesUpdate schemas`
- ``backend/services/email.py` — new module with compose_digest_html, send_email, is_smtp_configured`
- ``alembic/versions/029_add_email_digest.py` — migration adding notification_preferences column and email_digest_log table`
## Verification
cd backend && python -c "from models import EmailDigestLog, User; from services.email import compose_digest_html, send_email, is_smtp_configured; from config import get_settings; s = get_settings(); assert not is_smtp_configured(s); print('OK')" && cd ../alembic && python -c "import importlib.util; spec = importlib.util.spec_from_file_location('m', 'versions/029_add_email_digest.py'); mod = importlib.util.module_from_spec(spec); print('Migration module loads OK')"

View file

@ -0,0 +1,85 @@
---
id: T01
parent: S01
milestone: M025
provides: []
requires: []
affects: []
key_files: ["backend/config.py", "backend/models.py", "backend/schemas.py", "backend/services/email.py", "alembic/versions/029_add_email_digest.py"]
key_decisions: ["OSError catch alongside SMTPException in send_email to handle DNS/network failures from socket layer"]
patterns_established: []
drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: "Ran task plan verification commands: model imports succeed, is_smtp_configured returns False with empty defaults, migration module loads. Ran 5 negative test cases: empty host/from_address rejected, unconfigured send returns False, empty content groups produce minimal HTML, SMTP connection failure returns False with logged exception, schema defaults correct."
completed_at: 2026-04-04T12:11:08.433Z
blocker_discovered: false
---
# T01: Added SMTP config, User notification_preferences JSONB, EmailDigestLog model, migration 029, email digest composer+sender, and notification preference schemas
> Added SMTP config, User notification_preferences JSONB, EmailDigestLog model, migration 029, email digest composer+sender, and notification preference schemas
## What Happened
---
id: T01
parent: S01
milestone: M025
key_files:
- backend/config.py
- backend/models.py
- backend/schemas.py
- backend/services/email.py
- alembic/versions/029_add_email_digest.py
key_decisions:
- OSError catch alongside SMTPException in send_email to handle DNS/network failures from socket layer
duration: ""
verification_result: passed
completed_at: 2026-04-04T12:11:08.433Z
blocker_discovered: false
---
# T01: Added SMTP config, User notification_preferences JSONB, EmailDigestLog model, migration 029, email digest composer+sender, and notification preference schemas
**Added SMTP config, User notification_preferences JSONB, EmailDigestLog model, migration 029, email digest composer+sender, and notification preference schemas**
## What Happened
Added six SMTP fields to config.py Settings with safe empty defaults. Added notification_preferences JSONB column to User model with server_default. Created EmailDigestLog model with composite index on (user_id, digest_sent_at). Created Alembic migration 029. Built email service module with compose_digest_html (inline-CSS grouped by creator), send_email (smtplib with STARTTLS, 10s timeout, bool return), and is_smtp_configured gate. Added NotificationPreferences and NotificationPreferencesUpdate Pydantic schemas.
## Verification
Ran task plan verification commands: model imports succeed, is_smtp_configured returns False with empty defaults, migration module loads. Ran 5 negative test cases: empty host/from_address rejected, unconfigured send returns False, empty content groups produce minimal HTML, SMTP connection failure returns False with logged exception, schema defaults correct.
## Verification Evidence
| # | Command | Exit Code | Verdict | Duration |
|---|---------|-----------|---------|----------|
| 1 | `python -c 'from models import EmailDigestLog, User; from services.email import ...; assert not is_smtp_configured(s)'` | 0 | ✅ pass | 1200ms |
| 2 | `python -c 'import importlib.util; spec = ...(029_add_email_digest.py)'` | 0 | ✅ pass | 300ms |
| 3 | `Negative tests: 5 cases (empty config, connection failure, empty groups)` | 0 | ✅ pass | 2100ms |
| 4 | `Schemas import + defaults validation` | 0 | ✅ pass | 400ms |
## Deviations
Added OSError catch in send_email alongside SMTPException — DNS resolution failures raise socket.gaierror (subclass of OSError), not SMTPException.
## Known Issues
None.
## Files Created/Modified
- `backend/config.py`
- `backend/models.py`
- `backend/schemas.py`
- `backend/services/email.py`
- `alembic/versions/029_add_email_digest.py`
## Deviations
Added OSError catch in send_email alongside SMTPException — DNS resolution failures raise socket.gaierror (subclass of OSError), not SMTPException.
## Known Issues
None.

View file

@ -0,0 +1,83 @@
---
estimated_steps: 44
estimated_files: 4
skills_used: []
---
# T02: Digest Celery task with Beat scheduling and Docker config
Build the digest orchestration: a Celery task that queries new content since each user's last digest, groups by followed creator, composes and sends emails via the email service, and logs successful sends. Configure Celery Beat to run it daily. Update docker-compose.yml to add --beat flag to the worker.
## Steps
1. Create `backend/tasks/notifications.py` with `send_digest_emails` Celery task:
- Import celery_app from worker module
- Use sync SQLAlchemy engine (same pattern as pipeline/stages.py — create sync engine from DATABASE_URL converted to postgresql://)
- Query all users where `notification_preferences->>'email_digests'` is true
- For each user, find their last EmailDigestLog entry (max digest_sent_at)
- Query CreatorFollow to get followed creator_ids
- For each followed creator: query Posts where `is_published=True AND created_at > last_digest_at`, query TechniquePages where `created_at > last_digest_at` (join through TechniquePageVideo → SourceVideo to match creator_id)
- Skip user if no new content across all followed creators
- Call `compose_digest_html` with grouped content, generate signed unsubscribe URL
- Call `send_email` — on success, INSERT EmailDigestLog with content_summary JSONB
- If `is_smtp_configured()` returns False, log warning and return early (graceful no-op)
- Log structured messages: task start, per-user send result, task complete with count
2. Add Celery Beat schedule to `backend/worker.py`:
```python
celery_app.conf.beat_schedule = {
'send-digest-emails': {
'task': 'tasks.notifications.send_digest_emails',
'schedule': crontab(hour=9, minute=0), # daily at 9am UTC
},
}
```
Import `from celery.schedules import crontab` at top.
3. Import `tasks.notifications` in worker.py alongside the existing `pipeline.stages` import so the task decorator registers.
4. Update `docker-compose.yml` worker command from `["celery", "-A", "worker", "worker", "--loglevel=info", "--concurrency=1"]` to `["celery", "-A", "worker", "worker", "--beat", "--loglevel=info", "--concurrency=1"]`.
5. Generate signed unsubscribe tokens: use `itsdangerous.URLSafeTimedSerializer` with `settings.app_secret_key`. Token encodes user_id. Unsubscribe URL format: `{base_url}/api/v1/notifications/unsubscribe?token={token}`. Token valid for 30 days.
## Failure Modes
| Dependency | On error | On timeout | On malformed response |
|------------|----------|-----------|----------------------|
| SMTP (via email service) | Log error per user, continue to next user | 10s timeout in email service | N/A |
| PostgreSQL | Task fails with exception, Celery retries | Standard SQLAlchemy timeout | N/A |
| Redis (Celery broker) | Task not dispatched, Beat retries next schedule | Celery handles reconnection | N/A |
## Load Profile
- Shared resources: sync DB session (one per task run), SMTP connection (one per email)
- Per-operation cost: ~3 DB queries per user (last digest, follows, content), 1 SMTP send per user
- 10x breakpoint: N/A — <50 users, volume is trivial. At 500+ users, batch SMTP connections.
## Must-Haves
- [ ] Digest task queries correct new content per followed creator
- [ ] Graceful no-op when SMTP is unconfigured
- [ ] EmailDigestLog written only on successful send (deduplication)
- [ ] Celery Beat schedule configured for daily run
- [ ] Docker worker command includes --beat flag
- [ ] Signed unsubscribe URL generated per email
## Inputs
- ``backend/services/email.py` — email service from T01`
- ``backend/models.py` — EmailDigestLog, User, CreatorFollow, Post, TechniquePage, TechniquePageVideo, SourceVideo models from T01`
- ``backend/config.py` — SMTP settings from T01`
- ``backend/worker.py` — existing Celery app config`
- ``docker-compose.yml` — existing worker service definition`
## Expected Output
- ``backend/tasks/__init__.py` — empty package init`
- ``backend/tasks/notifications.py` — send_digest_emails Celery task with content query, grouping, email send, and logging`
- ``backend/worker.py` — Beat schedule added, tasks.notifications import added`
- ``docker-compose.yml` — worker command updated with --beat flag`
## Verification
cd backend && python -c "from tasks.notifications import send_digest_emails; print('Task imports OK')" && python -c "from worker import celery_app; assert 'send-digest-emails' in celery_app.conf.beat_schedule; print('Beat schedule OK')" && grep -q '\-\-beat' ../docker-compose.yml && echo 'Docker beat flag OK'

View file

@ -0,0 +1,73 @@
---
estimated_steps: 33
estimated_files: 6
skills_used: []
---
# T03: Notification preferences API, unsubscribe endpoint, frontend settings, and integration test
Wire the user-facing surfaces: API endpoints for reading/updating notification preferences, a public unsubscribe endpoint (no auth required, uses signed token), a frontend settings toggle in the creator settings page, and an integration test that verifies the full digest flow with mocked SMTP.
## Steps
1. Create `backend/routers/notifications.py`:
- `GET /notifications/preferences` — returns current user's notification_preferences JSONB. Requires auth.
- `PUT /notifications/preferences` — updates notification_preferences. Validates against NotificationPreferencesUpdate schema. Requires auth.
- `GET /notifications/unsubscribe` — accepts `token` query param. Decodes with `itsdangerous.URLSafeTimedSerializer` (max_age=30 days). Sets `notification_preferences.email_digests = False` for the user. Returns simple HTML page confirming unsubscription. No auth required.
2. Mount the router in `backend/main.py`: `app.include_router(notifications.router, prefix="/api/v1")`
3. Create `frontend/src/api/notifications.ts`:
- `getNotificationPreferences()` — GET /api/v1/notifications/preferences
- `updateNotificationPreferences(prefs)` — PUT /api/v1/notifications/preferences
4. Add notification settings section to `frontend/src/pages/CreatorSettings.tsx`:
- New section "Email Notifications" with a toggle switch for email digests (on/off)
- Frequency selector (daily/weekly) shown only when digests are enabled
- Fetch current prefs on mount, PUT on change with optimistic UI update
- Use existing form/toggle patterns from the page
5. Create integration test `backend/tests/test_notifications.py`:
- Test GET/PUT preferences endpoints (auth required, returns correct shape)
- Test unsubscribe endpoint with valid and expired tokens
- Test digest task end-to-end: create test user, creator, follow, published post, mock SMTP, run `send_digest_emails`, assert email was "sent" (mock called with correct args), assert EmailDigestLog created
- Test digest task skips when no new content
- Test digest task no-op when SMTP unconfigured
6. Add `itsdangerous` to `requirements.txt` if not already present.
## Negative Tests
- PUT preferences with invalid digest_frequency value → 422
- Unsubscribe with expired token (>30 days) → error page
- Unsubscribe with tampered token → error page
- GET/PUT preferences without auth → 401
## Must-Haves
- [ ] GET/PUT notification preferences endpoints with auth
- [ ] Unsubscribe endpoint works without auth via signed token
- [ ] Frontend toggle for email digests in creator settings
- [ ] Integration test covers digest task happy path with mocked SMTP
- [ ] Integration test covers unsubscribe with valid and invalid tokens
## Inputs
- ``backend/services/email.py` — email service from T01`
- ``backend/models.py` — models from T01`
- ``backend/schemas.py` — notification preference schemas from T01`
- ``backend/config.py` — SMTP settings from T01`
- ``backend/tasks/notifications.py` — digest task from T02`
- ``frontend/src/pages/CreatorSettings.tsx` — existing settings page to extend`
## Expected Output
- ``backend/routers/notifications.py` — GET/PUT preferences + unsubscribe endpoints`
- ``backend/main.py` — notifications router mounted`
- ``backend/tests/test_notifications.py` — integration tests for preferences, unsubscribe, and digest task`
- ``frontend/src/api/notifications.ts` — API client functions`
- ``frontend/src/pages/CreatorSettings.tsx` — notification settings section added`
## Verification
cd backend && python -m pytest tests/test_notifications.py -v --timeout=30 2>&1 | tail -20

File diff suppressed because one or more lines are too long

View file

@ -130,7 +130,7 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}
</div>
<div class="hdr-right">
<span class="gen-lbl">Updated</span>
<span class="gen">Apr 4, 2026, 10:23 AM</span>
<span class="gen">Apr 4, 2026, 12:02 PM</span>
</div>
</div>
</header>
@ -168,6 +168,10 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}
<div class="toc-group-label">M023</div>
<ul><li><a href="M023-2026-04-04T10-23-24.html">Apr 4, 2026, 10:23 AM</a> <span class="toc-kind toc-milestone">milestone</span></li></ul>
</div>
<div class="toc-group">
<div class="toc-group-label">M024</div>
<ul><li><a href="M024-2026-04-04T12-02-28.html">Apr 4, 2026, 12:02 PM</a> <span class="toc-kind toc-milestone">milestone</span></li></ul>
</div>
</aside>
<!-- Main content -->
@ -176,47 +180,49 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}
<h2>Project Overview</h2>
<div class="idx-summary">
<div class="idx-stat"><span class="idx-val">$550.85</span><span class="idx-lbl">Total Cost</span></div>
<div class="idx-stat"><span class="idx-val">777.32M</span><span class="idx-lbl">Total Tokens</span></div>
<div class="idx-stat"><span class="idx-val">23h 59m</span><span class="idx-lbl">Duration</span></div>
<div class="idx-stat"><span class="idx-val">106/123</span><span class="idx-lbl">Slices</span></div>
<div class="idx-stat"><span class="idx-val">23/25</span><span class="idx-lbl">Milestones</span></div>
<div class="idx-stat"><span class="idx-val">7</span><span class="idx-lbl">Reports</span></div>
<div class="idx-stat"><span class="idx-val">$590.21</span><span class="idx-lbl">Total Cost</span></div>
<div class="idx-stat"><span class="idx-val">835.08M</span><span class="idx-lbl">Total Tokens</span></div>
<div class="idx-stat"><span class="idx-val">25h 38m</span><span class="idx-lbl">Duration</span></div>
<div class="idx-stat"><span class="idx-val">112/123</span><span class="idx-lbl">Slices</span></div>
<div class="idx-stat"><span class="idx-val">24/25</span><span class="idx-lbl">Milestones</span></div>
<div class="idx-stat"><span class="idx-val">8</span><span class="idx-lbl">Reports</span></div>
</div>
<div class="idx-progress">
<div class="idx-bar-track"><div class="idx-bar-fill" style="width:86%"></div></div>
<span class="idx-pct">86% complete</span>
<div class="idx-bar-track"><div class="idx-bar-fill" style="width:91%"></div></div>
<span class="idx-pct">91% complete</span>
</div>
<div class="sparkline-wrap"><h3>Cost Progression</h3>
<div class="sparkline">
<svg viewBox="0 0 600 60" width="600" height="60" class="spark-svg">
<polyline points="12.0,36.7 108.0,36.2 204.0,24.1 300.0,21.1 396.0,16.3 492.0,14.2 588.0,12.0" class="spark-line" fill="none"/>
<circle cx="12.0" cy="36.7" r="3" class="spark-dot">
<polyline points="12.0,37.5 94.3,37.0 176.6,25.7 258.9,22.9 341.1,18.4 423.4,16.5 505.7,14.4 588.0,12.0" class="spark-line" fill="none"/>
<circle cx="12.0" cy="37.5" r="3" class="spark-dot">
<title>M008: M008: Credibility Debt Cleanup — Broken Links, Test Data, Jargon, Empty Metrics — $172.23</title>
</circle><circle cx="108.0" cy="36.2" r="3" class="spark-dot">
</circle><circle cx="94.3" cy="37.0" r="3" class="spark-dot">
<title>M009: Homepage &amp; First Impression — $180.97</title>
</circle><circle cx="204.0" cy="24.1" r="3" class="spark-dot">
</circle><circle cx="176.6" cy="25.7" r="3" class="spark-dot">
<title>M018: M018: Phase 2 Research &amp; Documentation — Site Audit and Forgejo Wiki Bootstrap — $365.18</title>
</circle><circle cx="300.0" cy="21.1" r="3" class="spark-dot">
</circle><circle cx="258.9" cy="22.9" r="3" class="spark-dot">
<title>M019: Foundations — Auth, Consent &amp; LightRAG — $411.26</title>
</circle><circle cx="396.0" cy="16.3" r="3" class="spark-dot">
</circle><circle cx="341.1" cy="18.4" r="3" class="spark-dot">
<title>M021: Intelligence Online — Chat, Chapters &amp; Search Cutover — $485.08</title>
</circle><circle cx="492.0" cy="14.2" r="3" class="spark-dot">
</circle><circle cx="423.4" cy="16.5" r="3" class="spark-dot">
<title>M022: Creator Tools &amp; Personality — $516.43</title>
</circle><circle cx="588.0" cy="12.0" r="3" class="spark-dot">
</circle><circle cx="505.7" cy="14.4" r="3" class="spark-dot">
<title>M023: MVP Integration — Demo Build — $550.85</title>
</circle><circle cx="588.0" cy="12.0" r="3" class="spark-dot">
<title>M024: Polish, Shorts Pipeline &amp; Citations — $590.21</title>
</circle>
<text x="12" y="58" class="spark-lbl">$172.23</text>
<text x="588" y="58" text-anchor="end" class="spark-lbl">$550.85</text>
<text x="588" y="58" text-anchor="end" class="spark-lbl">$590.21</text>
</svg>
<div class="spark-axis">
<span class="spark-tick" style="left:2.0%" title="2026-03-31T05:31:26.249Z">M008</span><span class="spark-tick" style="left:18.0%" title="2026-03-31T05:52:28.456Z">M009</span><span class="spark-tick" style="left:34.0%" title="2026-04-03T21:17:51.201Z">M018</span><span class="spark-tick" style="left:50.0%" title="2026-04-03T23:30:16.641Z">M019</span><span class="spark-tick" style="left:66.0%" title="2026-04-04T06:50:37.759Z">M021</span><span class="spark-tick" style="left:82.0%" title="2026-04-04T08:51:51.223Z">M022</span><span class="spark-tick" style="left:98.0%" title="2026-04-04T10:23:24.247Z">M023</span>
<span class="spark-tick" style="left:2.0%" title="2026-03-31T05:31:26.249Z">M008</span><span class="spark-tick" style="left:15.7%" title="2026-03-31T05:52:28.456Z">M009</span><span class="spark-tick" style="left:29.4%" title="2026-04-03T21:17:51.201Z">M018</span><span class="spark-tick" style="left:43.1%" title="2026-04-03T23:30:16.641Z">M019</span><span class="spark-tick" style="left:56.9%" title="2026-04-04T06:50:37.759Z">M021</span><span class="spark-tick" style="left:70.6%" title="2026-04-04T08:51:51.223Z">M022</span><span class="spark-tick" style="left:84.3%" title="2026-04-04T10:23:24.247Z">M023</span><span class="spark-tick" style="left:98.0%" title="2026-04-04T12:02:28.692Z">M024</span>
</div>
</div></div>
</section>
<section class="idx-cards">
<h2>Progression <span class="sec-count">7</span></h2>
<h2>Progression <span class="sec-count">8</span></h2>
<div class="cards-grid">
<a class="report-card" href="M008-2026-03-31T05-31-26.html">
<div class="card-top">
@ -344,7 +350,7 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}
<div class="card-delta"><span>+$31.35</span><span>+7 slices</span><span>+1 milestone</span></div>
</a>
<a class="report-card card-latest" href="M023-2026-04-04T10-23-24.html">
<a class="report-card" href="M023-2026-04-04T10-23-24.html">
<div class="card-top">
<span class="card-label">M023: MVP Integration — Demo Build</span>
<span class="card-kind card-kind-milestone">milestone</span>
@ -363,6 +369,27 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}
<span>106/123 slices</span>
</div>
<div class="card-delta"><span>+$34.42</span><span>+5 slices</span><span>+1 milestone</span></div>
</a>
<a class="report-card card-latest" href="M024-2026-04-04T12-02-28.html">
<div class="card-top">
<span class="card-label">M024: Polish, Shorts Pipeline &amp; Citations</span>
<span class="card-kind card-kind-milestone">milestone</span>
</div>
<div class="card-date">Apr 4, 2026, 12:02 PM</div>
<div class="card-progress">
<div class="card-bar-track">
<div class="card-bar-fill" style="width:91%"></div>
</div>
<span class="card-pct">91%</span>
</div>
<div class="card-stats">
<span>$590.21</span>
<span>835.08M</span>
<span>25h 38m</span>
<span>112/123 slices</span>
</div>
<div class="card-delta"><span>+$39.36</span><span>+6 slices</span><span>+1 milestone</span></div>
<div class="card-latest-badge">Latest</div>
</a></div>
</section>
@ -377,7 +404,7 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}
<span class="ftr-sep"></span>
<span>/home/aux/projects/content-to-kb-automator</span>
<span class="ftr-sep"></span>
<span>Updated Apr 4, 2026, 10:23 AM</span>
<span>Updated Apr 4, 2026, 12:02 PM</span>
</div>
</footer>
</body>

View file

@ -115,6 +115,22 @@
"doneMilestones": 23,
"totalMilestones": 25,
"phase": "planning"
},
{
"filename": "M024-2026-04-04T12-02-28.html",
"generatedAt": "2026-04-04T12:02:28.692Z",
"milestoneId": "M024",
"milestoneTitle": "Polish, Shorts Pipeline & Citations",
"label": "M024: Polish, Shorts Pipeline & Citations",
"kind": "milestone",
"totalCost": 590.205655,
"totalTokens": 835082375,
"totalDuration": 92306404,
"doneSlices": 112,
"totalSlices": 123,
"doneMilestones": 24,
"totalMilestones": 25,
"phase": "planning"
}
]
}

View 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")

View file

@ -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"

View file

@ -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"

View file

@ -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

161
backend/services/email.py Normal file
View 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>"""