From 1f9f7f39d7dd4dcd2fd67be6f66e8011557aa454 Mon Sep 17 00:00:00 2001 From: oabrivard Date: Sat, 21 Mar 2026 21:34:07 +0100 Subject: [PATCH] Phase 7: Email sending via Resend + Markdown/PDF export Backend: - Synthesis email sending via Resend API with HTML template (inline CSS, tables-based for email client compatibility) + plain-text fallback - XSS prevention via html_escape() on all user content in email templates - Markdown export: clean format with headers, links, summaries - PDF export: printpdf with built-in Helvetica fonts, indigo color scheme, automatic page breaks, word wrapping - 3 new endpoints: send-email, export/markdown, export/pdf - All endpoints enforce ownership checks - Email validation using email_address crate - 24 new unit tests, 13 integration tests Frontend: - Email section on SynthesisDetail: input pre-filled with user email, send button with loading state, success/error feedback - Export buttons: Markdown + PDF with per-button loading states - File download via Blob + temporary anchor with Content-Disposition parsing - 6 new export tests Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/settings.local.json | 7 +- backend/Cargo.lock | 176 ++++++ backend/Cargo.toml | 3 + backend/src/handlers/syntheses.rs | 131 +++- backend/src/models/synthesis.rs | 64 ++ backend/src/router.rs | 4 + backend/src/services/email.rs | 272 ++++++++- backend/src/services/export.rs | 456 ++++++++++++++ backend/src/services/mod.rs | 1 + backend/tests/api_export_test.rs | 574 ++++++++++++++++++ backend/tests/common/mod.rs | 28 + .../src/__tests__/synthesis-export.test.ts | 194 ++++++ frontend/src/api/syntheses.ts | 69 ++- frontend/src/i18n/fr.ts | 16 + frontend/src/pages/SynthesisDetail.tsx | 183 +++++- frontend/src/types.ts | 4 + 16 files changed, 2176 insertions(+), 6 deletions(-) create mode 100644 backend/src/services/export.rs create mode 100644 backend/tests/api_export_test.rs create mode 100644 frontend/src/__tests__/synthesis-export.test.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 787842b..17252d0 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -8,7 +8,12 @@ "Bash(git commit:*)", "Bash(xargs ls:*)", "Bash(/tmp/check_sse.txt:*)", - "Read(//tmp/**)" + "Read(//tmp/**)", + "Bash(cargo check:*)", + "Bash(cargo test:*)", + "Bash(npx tsc:*)", + "Bash(npx vitest:*)", + "Bash(npx vite:*)" ], "defaultMode": "bypassPermissions" } diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 06bc433..08dc5f6 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aead" version = "0.5.2" @@ -64,6 +70,7 @@ dependencies = [ "futures", "hex", "http-body-util", + "printpdf", "rand", "reqwest", "scraper", @@ -281,6 +288,17 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -450,6 +468,15 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -533,6 +560,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + [[package]] name = "derive_more" version = "0.99.20" @@ -671,6 +707,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "flume" version = "0.11.1" @@ -1362,6 +1408,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1389,6 +1441,23 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lopdf" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c8e1b6184b1b32ea5f72f572ebdc40e5da1d2921fa469947ff7c480ad1f85a" +dependencies = [ + "encoding_rs", + "flate2", + "itoa", + "linked-hash-map", + "log", + "md5", + "pom", + "time", + "weezl", +] + [[package]] name = "mac" version = "0.1.1" @@ -1445,6 +1514,12 @@ dependencies = [ "digest", ] +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + [[package]] name = "memchr" version = "2.8.0" @@ -1467,6 +1542,16 @@ dependencies = [ "unicase", ] +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.1.1" @@ -1543,6 +1628,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + [[package]] name = "num-integer" version = "0.1.46" @@ -1635,6 +1726,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "owned_ttf_parser" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "706de7e2214113d63a8238d1910463cfce781129a6f263d13fdb09ff64355ba4" +dependencies = [ + "ttf-parser", +] + [[package]] name = "parking" version = "2.2.1" @@ -1788,6 +1888,15 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "pom" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c972d8f86e943ad532d0b04e8965a749ad1d18bb981a9c7b3ae72fe7fd7744b" +dependencies = [ + "bstr", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -1797,6 +1906,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1822,6 +1937,18 @@ dependencies = [ "syn", ] +[[package]] +name = "printpdf" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c30a4cc87c3ca9a98f4970db158a7153f8d1ec8076e005751173c57836380b1d" +dependencies = [ + "js-sys", + "lopdf", + "owned_ttf_parser", + "time", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -2254,6 +2381,12 @@ dependencies = [ "rand_core", ] +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + [[package]] name = "siphasher" version = "1.0.2" @@ -2661,6 +2794,37 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -2896,6 +3060,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49d64318d8311fc2668e48b63969f4343e0a85c4a109aa8460d6672e364b8bd1" + [[package]] name = "typenum" version = "1.19.0" @@ -3183,6 +3353,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "whoami" version = "1.6.1" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 2174aa4..b94ba2b 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -75,6 +75,9 @@ dashmap = "6" tokio-stream = { version = "0.1", features = ["sync"] } futures = "0.3" +# PDF generation +printpdf = "0.7" + [dev-dependencies] tower = { version = "0.5", features = ["util"] } http-body-util = "0.1" diff --git a/backend/src/handlers/syntheses.rs b/backend/src/handlers/syntheses.rs index 75e34ad..2c11344 100644 --- a/backend/src/handlers/syntheses.rs +++ b/backend/src/handlers/syntheses.rs @@ -3,9 +3,12 @@ //! - `GET /api/v1/syntheses` — list user's syntheses (paginated) //! - `GET /api/v1/syntheses/:id` — get synthesis detail //! - `DELETE /api/v1/syntheses/:id` — delete a synthesis +//! - `POST /api/v1/syntheses/:id/send-email` — send synthesis by email +//! - `GET /api/v1/syntheses/:id/export/markdown` — export as Markdown +//! - `GET /api/v1/syntheses/:id/export/pdf` — export as PDF use axum::extract::{Path, Query, State}; -use axum::http::StatusCode; +use axum::http::{header, StatusCode}; use axum::response::IntoResponse; use axum::Json; use serde::{Deserialize, Serialize}; @@ -15,7 +18,11 @@ use crate::app_state::AppState; use crate::db; use crate::errors::AppError; use crate::middleware::auth::AuthUser; -use crate::models::synthesis::{SynthesisListItem, SynthesisResponse}; +use crate::models::synthesis::{ + NewsSection, SendEmailRequest, SynthesisListItem, SynthesisResponse, +}; +use crate::services::email; +use crate::services::export; /// Query parameters for `GET /api/v1/syntheses`. #[derive(Debug, Deserialize)] @@ -97,3 +104,123 @@ pub async fn delete( Ok(StatusCode::NO_CONTENT) } + +/// Helper: fetch a synthesis for the authenticated user and parse its sections. +async fn fetch_owned_synthesis( + state: &AppState, + user_id: uuid::Uuid, + synthesis_id: uuid::Uuid, +) -> Result<(String, String, Vec), AppError> { + let synthesis = db::syntheses::get_by_id_for_user(&state.pool, synthesis_id, user_id) + .await? + .ok_or_else(|| AppError::NotFound("Synthese introuvable.".into()))?; + + let sections: Vec = + serde_json::from_value(synthesis.sections).map_err(|e| { + AppError::Internal(anyhow::anyhow!( + "Failed to parse synthesis sections: {}", + e + )) + })?; + + let date = synthesis.created_at.format("%Y-%m-%d").to_string(); + + Ok((synthesis.week, date, sections)) +} + +/// Response for `POST /api/v1/syntheses/:id/send-email`. +#[derive(Debug, Serialize)] +pub struct SendEmailResponse { + pub message: String, +} + +/// `POST /api/v1/syntheses/:id/send-email` +/// +/// Sends the synthesis to the specified email address via Resend. +/// Validates the email address and enforces ownership. +pub async fn send_email( + auth_user: AuthUser, + State(state): State, + Path(id): Path, + Json(body): Json, +) -> Result { + body.validate()?; + + let (week, date, sections) = fetch_owned_synthesis(&state, auth_user.id, id).await?; + + email::send_synthesis_email( + &state.http_client, + &state.config.resend_api_key, + &state.config.email_from, + &body.email, + &week, + &date, + §ions, + ) + .await?; + + tracing::info!( + user_id = %auth_user.id, + synthesis_id = %id, + to = %body.email, + "Synthesis sent by email" + ); + + Ok(Json(SendEmailResponse { + message: "Email envoye avec succes.".into(), + })) +} + +/// `GET /api/v1/syntheses/:id/export/markdown` +/// +/// Returns the synthesis as a downloadable Markdown file. +/// Content-Type: text/markdown, with Content-Disposition for download. +pub async fn export_markdown( + auth_user: AuthUser, + State(state): State, + Path(id): Path, +) -> Result { + let (week, date, sections) = fetch_owned_synthesis(&state, auth_user.id, id).await?; + + let markdown = export::generate_markdown(&week, &date, §ions); + let filename = format!("synthese-{}.md", week); + + Ok(( + StatusCode::OK, + [ + (header::CONTENT_TYPE, "text/markdown; charset=utf-8".to_string()), + ( + header::CONTENT_DISPOSITION, + format!("attachment; filename=\"{}\"", filename), + ), + ], + markdown, + )) +} + +/// `GET /api/v1/syntheses/:id/export/pdf` +/// +/// Returns the synthesis as a downloadable PDF file. +/// Content-Type: application/pdf, with Content-Disposition for download. +pub async fn export_pdf( + auth_user: AuthUser, + State(state): State, + Path(id): Path, +) -> Result { + let (week, date, sections) = fetch_owned_synthesis(&state, auth_user.id, id).await?; + + let pdf_bytes = export::generate_pdf(&week, &date, §ions)?; + let filename = format!("synthese-{}.pdf", week); + + Ok(( + StatusCode::OK, + [ + (header::CONTENT_TYPE, "application/pdf".to_string()), + ( + header::CONTENT_DISPOSITION, + format!("attachment; filename=\"{}\"", filename), + ), + ], + pdf_bytes, + )) +} diff --git a/backend/src/models/synthesis.rs b/backend/src/models/synthesis.rs index 619f840..b78a1ae 100644 --- a/backend/src/models/synthesis.rs +++ b/backend/src/models/synthesis.rs @@ -112,6 +112,30 @@ pub fn get_iso_week_string(date: NaiveDate) -> String { format!("{}-W{:02}", iso.year(), iso.week()) } +/// Request body for `POST /api/v1/syntheses/:id/send-email`. +#[derive(Debug, Deserialize)] +pub struct SendEmailRequest { + pub email: String, +} + +impl SendEmailRequest { + /// Validate the email address format. + pub fn validate(&self) -> Result<(), crate::errors::AppError> { + if self.email.trim().is_empty() { + return Err(crate::errors::AppError::Validation( + "L'adresse email est requise.".into(), + )); + } + // Use the email_address crate for RFC 5321 validation + if self.email.parse::().is_err() { + return Err(crate::errors::AppError::Validation( + "Adresse email invalide.".into(), + )); + } + Ok(()) + } +} + /// Scraped data for a news item, used during the rewrite pass. /// /// Combines the original LLM-generated item with content scraped @@ -287,4 +311,44 @@ mod tests { assert!(SynthesisResponse::try_from(synthesis).is_err()); } + + #[test] + fn send_email_request_valid_email() { + let req = SendEmailRequest { + email: "user@example.com".into(), + }; + assert!(req.validate().is_ok()); + } + + #[test] + fn send_email_request_empty_email_rejected() { + let req = SendEmailRequest { + email: "".into(), + }; + assert!(req.validate().is_err()); + } + + #[test] + fn send_email_request_whitespace_only_rejected() { + let req = SendEmailRequest { + email: " ".into(), + }; + assert!(req.validate().is_err()); + } + + #[test] + fn send_email_request_invalid_format_rejected() { + let req = SendEmailRequest { + email: "not-an-email".into(), + }; + assert!(req.validate().is_err()); + } + + #[test] + fn send_email_request_missing_domain_rejected() { + let req = SendEmailRequest { + email: "user@".into(), + }; + assert!(req.validate().is_err()); + } } diff --git a/backend/src/router.rs b/backend/src/router.rs index dbf2d62..2e62291 100644 --- a/backend/src/router.rs +++ b/backend/src/router.rs @@ -57,6 +57,10 @@ pub fn build_router(state: AppState, config: &AppConfig) -> Router { .route("/syntheses", get(handlers::syntheses::list)) .route("/syntheses/{id}", get(handlers::syntheses::get)) .route("/syntheses/{id}", delete(handlers::syntheses::delete)) + // Syntheses email & export routes (authenticated) + .route("/syntheses/{id}/send-email", post(handlers::syntheses::send_email)) + .route("/syntheses/{id}/export/markdown", get(handlers::syntheses::export_markdown)) + .route("/syntheses/{id}/export/pdf", get(handlers::syntheses::export_pdf)) // Public config (authenticated, non-admin) .route("/config/providers", get(handlers::config::list_enabled_providers)) // Admin routes diff --git a/backend/src/services/email.rs b/backend/src/services/email.rs index 943c938..9260a27 100644 --- a/backend/src/services/email.rs +++ b/backend/src/services/email.rs @@ -1,11 +1,12 @@ //! Email sending service via the Resend HTTP API. //! -//! Used to send magic link emails for authentication. +//! Used to send magic link emails for authentication and synthesis delivery. //! Resend is a transactional email service that handles SPF/DKIM/DMARC. use serde::Serialize; use crate::errors::AppError; +use crate::models::synthesis::NewsSection; /// Resend API endpoint. const RESEND_API_URL: &str = "https://api.resend.com/emails"; @@ -17,6 +18,8 @@ struct ResendEmailRequest<'a> { to: Vec<&'a str>, subject: &'a str, html: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + text: Option<&'a str>, } /// Test API key that bypasses the external Resend API call. @@ -76,6 +79,7 @@ pub async fn send_magic_link( to: vec![to], subject: "Votre lien de connexion - AI Weekly Synth", html: &html, + text: None, }; let response = client @@ -108,3 +112,269 @@ pub async fn send_magic_link( tracing::info!(to = to, "Magic link email sent successfully"); Ok(()) } + +/// Escape special HTML characters to prevent XSS in email templates. +fn html_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +/// Build the HTML email body for a synthesis. +/// +/// Uses a tables-based layout with inline CSS for maximum email client +/// compatibility. The color scheme matches the app's indigo theme. +pub fn build_synthesis_html(week: &str, date: &str, sections: &[NewsSection]) -> String { + let mut items_html = String::new(); + + for section in sections { + items_html.push_str(&format!( + r#" +

{title}

+ "#, + title = html_escape(§ion.title), + )); + + for item in §ion.items { + items_html.push_str(&format!( + r#" + {title} +

{summary}

+ "#, + url = html_escape(&item.url), + title = html_escape(&item.title), + summary = html_escape(&item.summary), + )); + } + } + + format!( + r#" + + + + + +
+ + + + + + + +
+

Synthese de la Semaine {week}

+

Generee le {date}

+
+ + {items} +
+
+

+ Genere par AI Weekly Synth +

+
+
+ +"#, + week = html_escape(week), + date = html_escape(date), + items = items_html, + ) +} + +/// Build the plain-text fallback for a synthesis email. +pub fn build_synthesis_text(week: &str, date: &str, sections: &[NewsSection]) -> String { + let mut text = String::new(); + + text.push_str(&format!("SYNTHESE DE LA SEMAINE {}\n", week)); + text.push_str(&format!("Generee le {}\n", date)); + text.push_str(&"=".repeat(50)); + text.push('\n'); + + for section in sections { + text.push_str(&format!("\n{}\n", section.title.to_uppercase())); + text.push_str(&"-".repeat(section.title.len())); + text.push('\n'); + + for item in §ion.items { + text.push_str(&format!("\n* {}\n", item.title)); + text.push_str(&format!(" {}\n", item.url)); + text.push_str(&format!(" {}\n", item.summary)); + } + } + + text.push_str("\n--\nGenere par AI Weekly Synth\n"); + text +} + +/// Send a synthesis by email to the specified recipient. +/// +/// Renders the synthesis as an HTML email with a plain-text fallback +/// and sends it via the Resend API. Reuses the same HTTP pattern as +/// [`send_magic_link`]. +/// +/// When `api_key` equals [`TEST_API_KEY`], the external call is skipped +/// and the function returns success immediately (used in integration tests). +pub async fn send_synthesis_email( + client: &reqwest::Client, + api_key: &str, + from: &str, + to: &str, + week: &str, + date: &str, + sections: &[NewsSection], +) -> Result<(), AppError> { + // Bypass for integration tests + if api_key == TEST_API_KEY { + tracing::debug!(to = to, "Synthesis email send bypassed (test mode)"); + return Ok(()); + } + + let html = build_synthesis_html(week, date, sections); + let text = build_synthesis_text(week, date, sections); + let subject = format!("Synthese de la Semaine {} - AI Weekly Synth", week); + + let request_body = ResendEmailRequest { + from, + to: vec![to], + subject: &subject, + html: &html, + text: Some(&text), + }; + + let response = client + .post(RESEND_API_URL) + .header("Authorization", format!("Bearer {}", api_key)) + .json(&request_body) + .send() + .await + .map_err(|e| { + tracing::error!("Failed to send synthesis email via Resend: {:?}", e); + AppError::Internal(anyhow::anyhow!("Failed to send synthesis email")) + })?; + + if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|_| "unknown".to_string()); + tracing::error!( + status = %status, + body = %body, + "Resend API returned error for synthesis email" + ); + return Err(AppError::Internal(anyhow::anyhow!( + "Email service returned status {}", status + ))); + } + + tracing::info!(to = to, week = week, "Synthesis email sent successfully"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::synthesis::NewsItem; + + fn sample_sections() -> Vec { + vec![ + NewsSection { + title: "Annonces Majeures".into(), + items: vec![ + NewsItem { + title: "OpenAI lance GPT-5".into(), + url: "https://openai.com/gpt5".into(), + summary: "OpenAI a annonce GPT-5.".into(), + }, + NewsItem { + title: "Google DeepMind Gemini 3".into(), + url: "https://deepmind.google/gemini3".into(), + summary: "DeepMind presente Gemini 3.".into(), + }, + ], + }, + NewsSection { + title: "Recherche".into(), + items: vec![NewsItem { + title: "Nouveau papier RLHF".into(), + url: "https://arxiv.org/abs/2026.12345".into(), + summary: "Approche RLHF prometteuse.".into(), + }], + }, + ] + } + + #[test] + fn html_email_contains_section_titles() { + let html = build_synthesis_html("2026-W12", "2026-03-21", &sample_sections()); + assert!(html.contains("Annonces Majeures")); + assert!(html.contains("Recherche")); + } + + #[test] + fn html_email_contains_item_titles_and_urls() { + let html = build_synthesis_html("2026-W12", "2026-03-21", &sample_sections()); + assert!(html.contains("OpenAI lance GPT-5")); + assert!(html.contains("https://openai.com/gpt5")); + assert!(html.contains("Nouveau papier RLHF")); + assert!(html.contains("https://arxiv.org/abs/2026.12345")); + } + + #[test] + fn html_email_contains_week_and_date() { + let html = build_synthesis_html("2026-W12", "2026-03-21", &sample_sections()); + assert!(html.contains("Semaine 2026-W12")); + assert!(html.contains("2026-03-21")); + } + + #[test] + fn html_email_has_inline_css() { + let html = build_synthesis_html("2026-W12", "2026-03-21", &sample_sections()); + // Verify inline styles are present (not external stylesheet) + assert!(html.contains("style=")); + assert!(!html.contains("")); + } + + #[test] + fn html_email_escapes_special_characters() { + let sections = vec![NewsSection { + title: "Test ".into(), + items: vec![NewsItem { + title: "Title with \"quotes\" & ".into(), + url: "https://example.com/test?a=1&b=2".into(), + summary: "Summary with bold attempt.".into(), + }], + }]; + + let html = build_synthesis_html("2026-W12", "2026-03-21", §ions); + assert!(!html.contains("