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) <noreply@anthropic.com>
master
oabrivard 3 months ago
parent 631bd43b9f
commit 1f9f7f39d7

@ -8,7 +8,12 @@
"Bash(git commit:*)", "Bash(git commit:*)",
"Bash(xargs ls:*)", "Bash(xargs ls:*)",
"Bash(/tmp/check_sse.txt:*)", "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" "defaultMode": "bypassPermissions"
} }

176
backend/Cargo.lock generated

@ -2,6 +2,12 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]] [[package]]
name = "aead" name = "aead"
version = "0.5.2" version = "0.5.2"
@ -64,6 +70,7 @@ dependencies = [
"futures", "futures",
"hex", "hex",
"http-body-util", "http-body-util",
"printpdf",
"rand", "rand",
"reqwest", "reqwest",
"scraper", "scraper",
@ -281,6 +288,17 @@ dependencies = [
"generic-array", "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]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.20.2" version = "3.20.2"
@ -450,6 +468,15 @@ version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" 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]] [[package]]
name = "crossbeam-queue" name = "crossbeam-queue"
version = "0.3.12" version = "0.3.12"
@ -533,6 +560,15 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
dependencies = [
"powerfmt",
]
[[package]] [[package]]
name = "derive_more" name = "derive_more"
version = "0.99.20" version = "0.99.20"
@ -671,6 +707,16 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" 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]] [[package]]
name = "flume" name = "flume"
version = "0.11.1" version = "0.11.1"
@ -1362,6 +1408,12 @@ dependencies = [
"vcpkg", "vcpkg",
] ]
[[package]]
name = "linked-hash-map"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.12.1" version = "0.12.1"
@ -1389,6 +1441,23 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" 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]] [[package]]
name = "mac" name = "mac"
version = "0.1.1" version = "0.1.1"
@ -1445,6 +1514,12 @@ dependencies = [
"digest", "digest",
] ]
[[package]]
name = "md5"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771"
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.0" version = "2.8.0"
@ -1467,6 +1542,16 @@ dependencies = [
"unicase", "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]] [[package]]
name = "mio" name = "mio"
version = "1.1.1" version = "1.1.1"
@ -1543,6 +1628,12 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "num-conv"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
[[package]] [[package]]
name = "num-integer" name = "num-integer"
version = "0.1.46" version = "0.1.46"
@ -1635,6 +1726,15 @@ dependencies = [
"vcpkg", "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]] [[package]]
name = "parking" name = "parking"
version = "2.2.1" version = "2.2.1"
@ -1788,6 +1888,15 @@ dependencies = [
"universal-hash", "universal-hash",
] ]
[[package]]
name = "pom"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c972d8f86e943ad532d0b04e8965a749ad1d18bb981a9c7b3ae72fe7fd7744b"
dependencies = [
"bstr",
]
[[package]] [[package]]
name = "potential_utf" name = "potential_utf"
version = "0.1.4" version = "0.1.4"
@ -1797,6 +1906,12 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]] [[package]]
name = "ppv-lite86" name = "ppv-lite86"
version = "0.2.21" version = "0.2.21"
@ -1822,6 +1937,18 @@ dependencies = [
"syn", "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]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.106" version = "1.0.106"
@ -2254,6 +2381,12 @@ dependencies = [
"rand_core", "rand_core",
] ]
[[package]]
name = "simd-adler32"
version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
[[package]] [[package]]
name = "siphasher" name = "siphasher"
version = "1.0.2" version = "1.0.2"
@ -2661,6 +2794,37 @@ dependencies = [
"cfg-if", "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]] [[package]]
name = "tinystr" name = "tinystr"
version = "0.8.2" version = "0.8.2"
@ -2896,6 +3060,12 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "ttf-parser"
version = "0.19.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49d64318d8311fc2668e48b63969f4343e0a85c4a109aa8460d6672e364b8bd1"
[[package]] [[package]]
name = "typenum" name = "typenum"
version = "1.19.0" version = "1.19.0"
@ -3183,6 +3353,12 @@ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]] [[package]]
name = "whoami" name = "whoami"
version = "1.6.1" version = "1.6.1"

@ -75,6 +75,9 @@ dashmap = "6"
tokio-stream = { version = "0.1", features = ["sync"] } tokio-stream = { version = "0.1", features = ["sync"] }
futures = "0.3" futures = "0.3"
# PDF generation
printpdf = "0.7"
[dev-dependencies] [dev-dependencies]
tower = { version = "0.5", features = ["util"] } tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1" http-body-util = "0.1"

@ -3,9 +3,12 @@
//! - `GET /api/v1/syntheses` — list user's syntheses (paginated) //! - `GET /api/v1/syntheses` — list user's syntheses (paginated)
//! - `GET /api/v1/syntheses/:id` — get synthesis detail //! - `GET /api/v1/syntheses/:id` — get synthesis detail
//! - `DELETE /api/v1/syntheses/:id` — delete a synthesis //! - `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::extract::{Path, Query, State};
use axum::http::StatusCode; use axum::http::{header, StatusCode};
use axum::response::IntoResponse; use axum::response::IntoResponse;
use axum::Json; use axum::Json;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -15,7 +18,11 @@ use crate::app_state::AppState;
use crate::db; use crate::db;
use crate::errors::AppError; use crate::errors::AppError;
use crate::middleware::auth::AuthUser; 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`. /// Query parameters for `GET /api/v1/syntheses`.
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -97,3 +104,123 @@ pub async fn delete(
Ok(StatusCode::NO_CONTENT) 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<NewsSection>), 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<NewsSection> =
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<AppState>,
Path(id): Path<Uuid>,
Json(body): Json<SendEmailRequest>,
) -> Result<impl IntoResponse, AppError> {
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,
&sections,
)
.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<AppState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
let (week, date, sections) = fetch_owned_synthesis(&state, auth_user.id, id).await?;
let markdown = export::generate_markdown(&week, &date, &sections);
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<AppState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
let (week, date, sections) = fetch_owned_synthesis(&state, auth_user.id, id).await?;
let pdf_bytes = export::generate_pdf(&week, &date, &sections)?;
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,
))
}

@ -112,6 +112,30 @@ pub fn get_iso_week_string(date: NaiveDate) -> String {
format!("{}-W{:02}", iso.year(), iso.week()) 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::<email_address::EmailAddress>().is_err() {
return Err(crate::errors::AppError::Validation(
"Adresse email invalide.".into(),
));
}
Ok(())
}
}
/// Scraped data for a news item, used during the rewrite pass. /// Scraped data for a news item, used during the rewrite pass.
/// ///
/// Combines the original LLM-generated item with content scraped /// Combines the original LLM-generated item with content scraped
@ -287,4 +311,44 @@ mod tests {
assert!(SynthesisResponse::try_from(synthesis).is_err()); 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());
}
} }

@ -57,6 +57,10 @@ pub fn build_router(state: AppState, config: &AppConfig) -> Router {
.route("/syntheses", get(handlers::syntheses::list)) .route("/syntheses", get(handlers::syntheses::list))
.route("/syntheses/{id}", get(handlers::syntheses::get)) .route("/syntheses/{id}", get(handlers::syntheses::get))
.route("/syntheses/{id}", delete(handlers::syntheses::delete)) .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) // Public config (authenticated, non-admin)
.route("/config/providers", get(handlers::config::list_enabled_providers)) .route("/config/providers", get(handlers::config::list_enabled_providers))
// Admin routes // Admin routes

@ -1,11 +1,12 @@
//! Email sending service via the Resend HTTP API. //! 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. //! Resend is a transactional email service that handles SPF/DKIM/DMARC.
use serde::Serialize; use serde::Serialize;
use crate::errors::AppError; use crate::errors::AppError;
use crate::models::synthesis::NewsSection;
/// Resend API endpoint. /// Resend API endpoint.
const RESEND_API_URL: &str = "https://api.resend.com/emails"; const RESEND_API_URL: &str = "https://api.resend.com/emails";
@ -17,6 +18,8 @@ struct ResendEmailRequest<'a> {
to: Vec<&'a str>, to: Vec<&'a str>,
subject: &'a str, subject: &'a str,
html: &'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. /// Test API key that bypasses the external Resend API call.
@ -76,6 +79,7 @@ pub async fn send_magic_link(
to: vec![to], to: vec![to],
subject: "Votre lien de connexion - AI Weekly Synth", subject: "Votre lien de connexion - AI Weekly Synth",
html: &html, html: &html,
text: None,
}; };
let response = client let response = client
@ -108,3 +112,269 @@ pub async fn send_magic_link(
tracing::info!(to = to, "Magic link email sent successfully"); tracing::info!(to = to, "Magic link email sent successfully");
Ok(()) Ok(())
} }
/// Escape special HTML characters to prevent XSS in email templates.
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
/// 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#"<tr><td style="padding: 24px 0 8px 0;">
<h2 style="margin: 0; font-size: 20px; color: #312E81; border-bottom: 2px solid #E0E7FF; padding-bottom: 8px;">{title}</h2>
</td></tr>"#,
title = html_escape(&section.title),
));
for item in &section.items {
items_html.push_str(&format!(
r#"<tr><td style="padding: 12px 0 12px 16px;">
<a href="{url}" style="font-size: 16px; font-weight: bold; color: #4F46E5; text-decoration: none;">{title}</a>
<p style="margin: 6px 0 0 0; font-size: 14px; color: #374151; line-height: 1.5;">{summary}</p>
</td></tr>"#,
url = html_escape(&item.url),
title = html_escape(&item.title),
summary = html_escape(&item.summary),
));
}
}
format!(
r#"<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head>
<body style="margin: 0; padding: 0; background-color: #F3F4F6; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color: #F3F4F6;">
<tr><td align="center" style="padding: 24px 16px;">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width: 600px; width: 100%; background-color: #FFFFFF; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
<!-- Header -->
<tr><td style="background-color: #4338CA; padding: 24px 32px;">
<h1 style="margin: 0; font-size: 24px; color: #FFFFFF;">Synthese de la Semaine {week}</h1>
<p style="margin: 8px 0 0 0; font-size: 14px; color: #C7D2FE;">Generee le {date}</p>
</td></tr>
<!-- Content -->
<tr><td style="padding: 16px 32px 32px 32px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
{items}
</table>
</td></tr>
<!-- Footer -->
<tr><td style="background-color: #F9FAFB; padding: 16px 32px; border-top: 1px solid #E5E7EB;">
<p style="margin: 0; font-size: 12px; color: #9CA3AF; text-align: center;">
Genere par AI Weekly Synth
</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>"#,
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 &section.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<NewsSection> {
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("<link rel=\"stylesheet\""));
assert!(!html.contains("<style>"));
}
#[test]
fn html_email_escapes_special_characters() {
let sections = vec![NewsSection {
title: "Test <script>alert('xss')</script>".into(),
items: vec![NewsItem {
title: "Title with \"quotes\" & <angle>".into(),
url: "https://example.com/test?a=1&b=2".into(),
summary: "Summary with <b>bold</b> attempt.".into(),
}],
}];
let html = build_synthesis_html("2026-W12", "2026-03-21", &sections);
assert!(!html.contains("<script>"));
assert!(html.contains("&lt;script&gt;"));
assert!(html.contains("&amp;"));
}
#[test]
fn text_fallback_contains_sections_and_items() {
let text = build_synthesis_text("2026-W12", "2026-03-21", &sample_sections());
assert!(text.contains("SYNTHESE DE LA SEMAINE 2026-W12"));
assert!(text.contains("Generee le 2026-03-21"));
assert!(text.contains("ANNONCES MAJEURES"));
assert!(text.contains("RECHERCHE"));
assert!(text.contains("* OpenAI lance GPT-5"));
assert!(text.contains("https://openai.com/gpt5"));
assert!(text.contains("Approche RLHF prometteuse."));
}
#[test]
fn text_fallback_empty_sections() {
let text = build_synthesis_text("2026-W12", "2026-03-21", &[]);
assert!(text.contains("SYNTHESE DE LA SEMAINE 2026-W12"));
assert!(text.contains("Generee le 2026-03-21"));
assert!(text.contains("Genere par AI Weekly Synth"));
}
}

@ -0,0 +1,456 @@
//! Export service for generating Markdown and PDF from a synthesis.
//!
//! Provides functions to convert a synthesis (with its sections and items)
//! into downloadable Markdown or PDF format.
use printpdf::*;
use std::io::BufWriter;
use crate::errors::AppError;
use crate::models::synthesis::NewsSection;
/// Generate a Markdown representation of the synthesis.
///
/// Output format:
/// ```text
/// # Synthese de la Semaine 2026-W12
///
/// Generee le 2026-03-21
///
/// ## Section Title
///
/// ### Article Title
///
/// [Lien](https://example.com/article)
///
/// Summary text here.
/// ```
pub fn generate_markdown(week: &str, date: &str, sections: &[NewsSection]) -> String {
let mut md = String::new();
md.push_str(&format!("# Synthese de la Semaine {}\n\n", week));
md.push_str(&format!("Generee le {}\n", date));
for section in sections {
md.push_str(&format!("\n## {}\n", section.title));
for item in &section.items {
md.push_str(&format!("\n### {}\n\n", item.title));
md.push_str(&format!("[Lien]({})\n\n", item.url));
md.push_str(&format!("{}\n", item.summary));
}
}
md
}
/// Page dimensions (A4 in mm).
const PAGE_WIDTH_MM: f32 = 210.0;
const PAGE_HEIGHT_MM: f32 = 297.0;
/// Margins in mm.
const MARGIN_LEFT: f32 = 25.0;
const MARGIN_TOP: f32 = 25.0;
const MARGIN_RIGHT: f32 = 25.0;
const MARGIN_BOTTOM: f32 = 25.0;
/// Maximum text width in mm.
const TEXT_WIDTH: f32 = PAGE_WIDTH_MM - MARGIN_LEFT - MARGIN_RIGHT;
/// Font sizes in pt.
const TITLE_SIZE: f32 = 18.0;
const DATE_SIZE: f32 = 10.0;
const SECTION_SIZE: f32 = 14.0;
const ITEM_TITLE_SIZE: f32 = 11.0;
const URL_SIZE: f32 = 8.0;
const BODY_SIZE: f32 = 10.0;
const FOOTER_SIZE: f32 = 8.0;
/// Approximate width of a character in mm for a given font size.
/// This is a rough estimate for Helvetica (average char width ~0.5 * size in pt).
/// 1 pt = 0.3528 mm.
fn approx_char_width_mm(font_size: f32) -> f32 {
font_size * 0.3528 * 0.5
}
/// Wrap text into lines that fit within the given width in mm.
fn wrap_text(text: &str, font_size: f32, max_width_mm: f32) -> Vec<String> {
let char_width = approx_char_width_mm(font_size);
let max_chars = (max_width_mm / char_width).floor() as usize;
if max_chars == 0 {
return vec![text.to_string()];
}
let mut lines = Vec::new();
for paragraph in text.split('\n') {
if paragraph.is_empty() {
lines.push(String::new());
continue;
}
let words: Vec<&str> = paragraph.split_whitespace().collect();
if words.is_empty() {
lines.push(String::new());
continue;
}
let mut current_line = String::new();
for word in words {
if current_line.is_empty() {
current_line = word.to_string();
} else if current_line.len() + 1 + word.len() <= max_chars {
current_line.push(' ');
current_line.push_str(word);
} else {
lines.push(current_line);
current_line = word.to_string();
}
}
if !current_line.is_empty() {
lines.push(current_line);
}
}
lines
}
/// PDF writer state: tracks current Y position and manages page creation.
struct PdfWriter {
doc: PdfDocumentReference,
current_page: PdfPageIndex,
current_layer: PdfLayerIndex,
y_pos: f32,
font_regular: IndirectFontRef,
font_bold: IndirectFontRef,
font_italic: IndirectFontRef,
}
impl PdfWriter {
fn new(
doc: PdfDocumentReference,
page: PdfPageIndex,
layer: PdfLayerIndex,
font_regular: IndirectFontRef,
font_bold: IndirectFontRef,
font_italic: IndirectFontRef,
) -> Self {
Self {
doc,
current_page: page,
current_layer: layer,
y_pos: PAGE_HEIGHT_MM - MARGIN_TOP,
font_regular,
font_bold,
font_italic,
}
}
/// Ensure there is enough vertical space; if not, add a new page.
fn ensure_space(&mut self, needed_mm: f32) {
if self.y_pos - needed_mm < MARGIN_BOTTOM {
let (page, layer) = self.doc.add_page(
Mm(PAGE_WIDTH_MM),
Mm(PAGE_HEIGHT_MM),
"Page",
);
self.current_page = page;
self.current_layer = layer;
self.y_pos = PAGE_HEIGHT_MM - MARGIN_TOP;
}
}
/// Write a single line of text at the current position.
fn write_line(&mut self, text: &str, font: &IndirectFontRef, size: f32, color: (f32, f32, f32)) {
let line_height_mm = size * 0.3528 * 1.4;
self.ensure_space(line_height_mm);
let layer = self.doc.get_page(self.current_page).get_layer(self.current_layer);
layer.set_fill_color(Color::Rgb(Rgb::new(color.0, color.1, color.2, None)));
layer.use_text(text, size, Mm(MARGIN_LEFT), Mm(self.y_pos), font);
self.y_pos -= line_height_mm;
}
/// Write wrapped text (may span multiple lines).
fn write_wrapped(
&mut self,
text: &str,
font: &IndirectFontRef,
size: f32,
color: (f32, f32, f32),
) {
let lines = wrap_text(text, size, TEXT_WIDTH);
for line in &lines {
self.write_line(line, font, size, color);
}
}
/// Add vertical spacing in mm.
fn space(&mut self, mm: f32) {
self.y_pos -= mm;
}
}
/// Indigo color (R, G, B) normalized to 0.0-1.0 for PDF.
const COLOR_INDIGO: (f32, f32, f32) = (0.263, 0.275, 0.898); // #4338CA
const COLOR_DARK: (f32, f32, f32) = (0.133, 0.133, 0.133); // #222222
const COLOR_GRAY: (f32, f32, f32) = (0.4, 0.4, 0.4); // #666666
const COLOR_LIGHT_GRAY: (f32, f32, f32) = (0.6, 0.6, 0.6); // #999999
/// Generate a PDF document from the synthesis, returned as raw bytes.
///
/// Uses the `printpdf` crate with built-in PDF fonts (Helvetica family).
/// No external font files are required.
///
/// The PDF contains:
/// - A title with "AI Weekly Synth - Semaine {week}"
/// - The generation date
/// - Section headers and items with titles, URLs, and summaries
/// - Automatic page breaks when content exceeds page height
pub fn generate_pdf(
week: &str,
date: &str,
sections: &[NewsSection],
) -> Result<Vec<u8>, AppError> {
let (doc, page, layer) = PdfDocument::new(
format!("AI Weekly Synth - Semaine {}", week),
Mm(PAGE_WIDTH_MM),
Mm(PAGE_HEIGHT_MM),
"Page 1",
);
let font_regular = doc.add_builtin_font(BuiltinFont::Helvetica).map_err(|e| {
AppError::Internal(anyhow::anyhow!("Failed to load Helvetica font: {}", e))
})?;
let font_bold = doc
.add_builtin_font(BuiltinFont::HelveticaBold)
.map_err(|e| {
AppError::Internal(anyhow::anyhow!("Failed to load Helvetica-Bold font: {}", e))
})?;
let font_italic = doc
.add_builtin_font(BuiltinFont::HelveticaOblique)
.map_err(|e| {
AppError::Internal(anyhow::anyhow!(
"Failed to load Helvetica-Oblique font: {}",
e
))
})?;
let mut writer = PdfWriter::new(doc, page, layer, font_regular, font_bold, font_italic);
// Title
let title = format!("AI Weekly Synth - Semaine {}", week);
writer.write_line(&title, &writer.font_bold.clone(), TITLE_SIZE, COLOR_INDIGO);
writer.space(3.0);
// Date
let date_line = format!("Generee le {}", date);
writer.write_line(
&date_line,
&writer.font_italic.clone(),
DATE_SIZE,
COLOR_GRAY,
);
writer.space(8.0);
// Sections
for section in sections {
// Section title
writer.write_line(
&section.title,
&writer.font_bold.clone(),
SECTION_SIZE,
COLOR_INDIGO,
);
writer.space(3.0);
for item in &section.items {
// Item title (bold)
writer.write_wrapped(
&item.title,
&writer.font_bold.clone(),
ITEM_TITLE_SIZE,
COLOR_DARK,
);
// URL (italic, gray)
writer.write_wrapped(
&item.url,
&writer.font_italic.clone(),
URL_SIZE,
COLOR_LIGHT_GRAY,
);
writer.space(1.0);
// Summary
writer.write_wrapped(
&item.summary,
&writer.font_regular.clone(),
BODY_SIZE,
COLOR_DARK,
);
writer.space(4.0);
}
writer.space(4.0);
}
// Footer
writer.space(8.0);
writer.write_line(
"Genere par AI Weekly Synth",
&writer.font_italic.clone(),
FOOTER_SIZE,
COLOR_LIGHT_GRAY,
);
// Render to bytes
let buf = Vec::new();
let mut buf_writer = BufWriter::new(buf);
writer
.doc
.save(&mut buf_writer)
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to render PDF: {}", e)))?;
let bytes = buf_writer
.into_inner()
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to flush PDF buffer: {}", e)))?;
Ok(bytes)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::synthesis::{NewsItem, NewsSection};
fn sample_sections() -> Vec<NewsSection> {
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 la sortie de GPT-5 avec des capacites ameliorees."
.into(),
},
NewsItem {
title: "Google DeepMind publie Gemini 3".into(),
url: "https://deepmind.google/gemini3".into(),
summary:
"DeepMind presente Gemini 3, son nouveau modele multimodal.".into(),
},
],
},
NewsSection {
title: "Recherche".into(),
items: vec![NewsItem {
title: "Nouveau papier sur le RLHF".into(),
url: "https://arxiv.org/abs/2026.12345".into(),
summary: "Une nouvelle approche du RLHF prometteuse.".into(),
}],
},
]
}
// --- Markdown tests ---
#[test]
fn markdown_contains_title() {
let md = generate_markdown("2026-W12", "2026-03-21", &sample_sections());
assert!(md.starts_with("# Synthese de la Semaine 2026-W12\n"));
}
#[test]
fn markdown_contains_date() {
let md = generate_markdown("2026-W12", "2026-03-21", &sample_sections());
assert!(md.contains("Generee le 2026-03-21"));
}
#[test]
fn markdown_contains_section_titles() {
let md = generate_markdown("2026-W12", "2026-03-21", &sample_sections());
assert!(md.contains("## Annonces Majeures"));
assert!(md.contains("## Recherche"));
}
#[test]
fn markdown_contains_item_titles_and_links() {
let md = generate_markdown("2026-W12", "2026-03-21", &sample_sections());
assert!(md.contains("### OpenAI lance GPT-5"));
assert!(md.contains("[Lien](https://openai.com/gpt5)"));
assert!(md.contains("### Nouveau papier sur le RLHF"));
assert!(md.contains("[Lien](https://arxiv.org/abs/2026.12345)"));
}
#[test]
fn markdown_contains_summaries() {
let md = generate_markdown("2026-W12", "2026-03-21", &sample_sections());
assert!(md.contains("OpenAI a annonce la sortie de GPT-5"));
assert!(md.contains("Une nouvelle approche du RLHF prometteuse."));
}
#[test]
fn markdown_empty_sections_produces_header_only() {
let md = generate_markdown("2026-W12", "2026-03-21", &[]);
assert!(md.contains("# Synthese de la Semaine 2026-W12"));
assert!(md.contains("Generee le 2026-03-21"));
// No section headers
assert!(!md.contains("##"));
}
// --- PDF tests ---
#[test]
fn pdf_output_is_nonempty_and_starts_with_pdf_header() {
let bytes = generate_pdf("2026-W12", "2026-03-21", &sample_sections()).unwrap();
assert!(!bytes.is_empty(), "PDF output should not be empty");
assert!(
bytes.starts_with(b"%PDF"),
"PDF output should start with %PDF magic bytes"
);
}
#[test]
fn pdf_empty_sections_still_produces_valid_pdf() {
let bytes = generate_pdf("2026-W12", "2026-03-21", &[]).unwrap();
assert!(!bytes.is_empty());
assert!(bytes.starts_with(b"%PDF"));
}
#[test]
fn pdf_contains_title_text() {
let bytes = generate_pdf("2026-W12", "2026-03-21", &sample_sections()).unwrap();
// The PDF binary content should contain the title string somewhere
let content = String::from_utf8_lossy(&bytes);
assert!(
content.contains("AI Weekly Synth"),
"PDF should contain the application name"
);
}
// --- Text wrapping tests ---
#[test]
fn wrap_text_short_line_fits() {
let lines = wrap_text("Hello world", 10.0, 160.0);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0], "Hello world");
}
#[test]
fn wrap_text_long_line_wraps() {
// Very narrow width to force wrapping
let lines = wrap_text("Hello world from the test", 10.0, 20.0);
assert!(lines.len() > 1, "Long text should wrap into multiple lines");
}
#[test]
fn wrap_text_empty_input() {
let lines = wrap_text("", 10.0, 160.0);
assert_eq!(lines.len(), 1);
assert!(lines[0].is_empty());
}
}

@ -2,6 +2,7 @@ pub mod auth;
pub mod csv; pub mod csv;
pub mod email; pub mod email;
pub mod encryption; pub mod encryption;
pub mod export;
pub mod llm; pub mod llm;
pub mod prompts; pub mod prompts;
pub mod rate_limiter; pub mod rate_limiter;

@ -0,0 +1,574 @@
//! Integration tests for Phase 7: Email + Export endpoints.
//!
//! Tests:
//! - 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
//!
//! Covers authentication, validation, ownership isolation, content-type
//! headers, and response body content for all three endpoints.
//!
//! Requires a running Postgres instance. Set `TEST_DATABASE_URL` to run.
mod common;
use axum::body::Body;
use axum::http::{Method, Request, StatusCode};
use ai_synth_backend::services::auth::SESSION_COOKIE_NAME;
fn require_test_db() -> bool {
std::env::var("TEST_DATABASE_URL").is_ok()
}
/// Helper: build a sample sections JSON value with two sections and three items.
fn sample_sections() -> serde_json::Value {
serde_json::json!([
{
"title": "Annonces Majeures",
"items": [
{
"title": "OpenAI lance GPT-5",
"url": "https://openai.com/gpt5",
"summary": "OpenAI a annonce GPT-5 avec des capacites ameliorees."
},
{
"title": "Google DeepMind publie Gemini 3",
"url": "https://deepmind.google/gemini3",
"summary": "DeepMind presente Gemini 3, son nouveau modele multimodal."
}
]
},
{
"title": "Recherche",
"items": [
{
"title": "Nouveau papier sur le RLHF",
"url": "https://arxiv.org/abs/2026.12345",
"summary": "Une nouvelle approche du RLHF prometteuse."
}
]
}
])
}
/// Build a GET request with a session cookie.
fn authed_get(uri: &str, session: &str) -> Request<Body> {
Request::builder()
.method(Method::GET)
.uri(uri)
.header("Cookie", format!("{}={}", SESSION_COOKIE_NAME, session))
.body(Body::empty())
.unwrap()
}
/// Build a GET request without a session cookie.
fn unauthed_get(uri: &str) -> Request<Body> {
Request::builder()
.method(Method::GET)
.uri(uri)
.body(Body::empty())
.unwrap()
}
// ═══════════════════════════════════════════════════════════════════════════
// Email Endpoint — POST /api/v1/syntheses/:id/send-email (5 tests)
// ═══════════════════════════════════════════════════════════════════════════
#[tokio::test]
async fn send_email_without_auth_returns_401() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let fake_id = uuid::Uuid::new_v4();
let body = serde_json::json!({ "email": "test@example.com" });
let (status, resp) = app
.post(&format!("/api/v1/syntheses/{}/send-email", fake_id), &body)
.await;
assert_eq!(
status,
StatusCode::UNAUTHORIZED,
"POST /syntheses/:id/send-email without auth should return 401"
);
assert_eq!(resp["error"], "unauthorized");
}
#[tokio::test]
async fn send_email_with_valid_email_returns_200() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (user_id, session) = app
.create_authenticated_user("email-ok@example.com")
.await;
let sections = sample_sections();
let synth_id = app
.insert_test_synthesis(user_id, "2026-W12", &sections)
.await;
let body = serde_json::json!({ "email": "recipient@example.com" });
let (status, resp) = app
.post_with_session(
&format!("/api/v1/syntheses/{}/send-email", synth_id),
&body,
&session,
)
.await;
assert_eq!(
status,
StatusCode::OK,
"POST /syntheses/:id/send-email with valid email should return 200"
);
assert!(
resp["message"].as_str().is_some(),
"Response should contain a success message"
);
}
#[tokio::test]
async fn send_email_with_invalid_email_returns_422() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (user_id, session) = app
.create_authenticated_user("email-invalid@example.com")
.await;
let sections = sample_sections();
let synth_id = app
.insert_test_synthesis(user_id, "2026-W12", &sections)
.await;
let body = serde_json::json!({ "email": "not-an-email" });
let (status, resp) = app
.post_with_session(
&format!("/api/v1/syntheses/{}/send-email", synth_id),
&body,
&session,
)
.await;
assert_eq!(
status,
StatusCode::UNPROCESSABLE_ENTITY,
"POST /syntheses/:id/send-email with invalid email should return 422"
);
assert_eq!(resp["error"], "validation_error");
}
#[tokio::test]
async fn send_email_for_non_owned_synthesis_returns_404() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (user_a_id, _session_a) = app
.create_authenticated_user("email-owner-a@example.com")
.await;
let (_user_b_id, session_b) = app
.create_authenticated_user("email-owner-b@example.com")
.await;
// Insert a synthesis for User A
let sections = sample_sections();
let synth_id = app
.insert_test_synthesis(user_a_id, "2026-W12", &sections)
.await;
// User B tries to send email for User A's synthesis
let body = serde_json::json!({ "email": "recipient@example.com" });
let (status, resp) = app
.post_with_session(
&format!("/api/v1/syntheses/{}/send-email", synth_id),
&body,
&session_b,
)
.await;
assert_eq!(
status,
StatusCode::NOT_FOUND,
"User B should not be able to send email for User A's synthesis"
);
assert_eq!(resp["error"], "not_found");
}
#[tokio::test]
async fn send_email_for_nonexistent_synthesis_returns_404() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (_user_id, session) = app
.create_authenticated_user("email-404@example.com")
.await;
let fake_id = uuid::Uuid::new_v4();
let body = serde_json::json!({ "email": "recipient@example.com" });
let (status, resp) = app
.post_with_session(
&format!("/api/v1/syntheses/{}/send-email", fake_id),
&body,
&session,
)
.await;
assert_eq!(
status,
StatusCode::NOT_FOUND,
"POST /syntheses/:id/send-email for non-existent synthesis should return 404"
);
assert_eq!(resp["error"], "not_found");
}
// ═══════════════════════════════════════════════════════════════════════════
// Markdown Export — GET /api/v1/syntheses/:id/export/markdown (4 tests)
// ═══════════════════════════════════════════════════════════════════════════
#[tokio::test]
async fn export_markdown_without_auth_returns_401() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let fake_id = uuid::Uuid::new_v4();
let req = unauthed_get(&format!("/api/v1/syntheses/{}/export/markdown", fake_id));
let response = app.raw_request(req).await;
assert_eq!(
response.status(),
StatusCode::UNAUTHORIZED,
"GET /syntheses/:id/export/markdown without auth should return 401"
);
}
#[tokio::test]
async fn export_markdown_returns_200_with_correct_content_type() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (user_id, session) = app
.create_authenticated_user("md-export@example.com")
.await;
let sections = sample_sections();
let synth_id = app
.insert_test_synthesis(user_id, "2026-W12", &sections)
.await;
let req = authed_get(
&format!("/api/v1/syntheses/{}/export/markdown", synth_id),
&session,
);
let (status, _text, headers) = app.raw_request_text(req).await;
assert_eq!(
status,
StatusCode::OK,
"GET /syntheses/:id/export/markdown should return 200"
);
let content_type = headers
.get("content-type")
.expect("Should have Content-Type header")
.to_str()
.unwrap();
assert!(
content_type.contains("text/markdown"),
"Content-Type should be text/markdown, got: {}",
content_type
);
let content_disposition = headers
.get("content-disposition")
.expect("Should have Content-Disposition header")
.to_str()
.unwrap();
assert!(
content_disposition.contains("attachment"),
"Content-Disposition should indicate attachment download"
);
assert!(
content_disposition.contains(".md"),
"Content-Disposition filename should end with .md"
);
}
#[tokio::test]
async fn export_markdown_body_contains_sections_and_items() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (user_id, session) = app
.create_authenticated_user("md-content@example.com")
.await;
let sections = sample_sections();
let synth_id = app
.insert_test_synthesis(user_id, "2026-W12", &sections)
.await;
let req = authed_get(
&format!("/api/v1/syntheses/{}/export/markdown", synth_id),
&session,
);
let (status, text, _headers) = app.raw_request_text(req).await;
assert_eq!(status, StatusCode::OK);
// Check title header
assert!(
text.contains("# Synthese de la Semaine 2026-W12"),
"Markdown should contain the synthesis title"
);
// Check section titles
assert!(
text.contains("## Annonces Majeures"),
"Markdown should contain section title 'Annonces Majeures'"
);
assert!(
text.contains("## Recherche"),
"Markdown should contain section title 'Recherche'"
);
// Check item titles
assert!(
text.contains("### OpenAI lance GPT-5"),
"Markdown should contain item title 'OpenAI lance GPT-5'"
);
assert!(
text.contains("### Google DeepMind publie Gemini 3"),
"Markdown should contain item title 'Google DeepMind publie Gemini 3'"
);
assert!(
text.contains("### Nouveau papier sur le RLHF"),
"Markdown should contain item title 'Nouveau papier sur le RLHF'"
);
// Check URLs are present as links
assert!(
text.contains("https://openai.com/gpt5"),
"Markdown should contain item URL"
);
assert!(
text.contains("https://arxiv.org/abs/2026.12345"),
"Markdown should contain item URL"
);
// Check summaries
assert!(
text.contains("OpenAI a annonce GPT-5"),
"Markdown should contain item summary"
);
}
#[tokio::test]
async fn export_markdown_for_non_owned_synthesis_returns_404() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (user_a_id, _session_a) = app
.create_authenticated_user("md-owner-a@example.com")
.await;
let (_user_b_id, session_b) = app
.create_authenticated_user("md-owner-b@example.com")
.await;
// Insert a synthesis for User A
let sections = sample_sections();
let synth_id = app
.insert_test_synthesis(user_a_id, "2026-W12", &sections)
.await;
// User B tries to export User A's synthesis
let req = authed_get(
&format!("/api/v1/syntheses/{}/export/markdown", synth_id),
&session_b,
);
let (status, _text, _headers) = app.raw_request_text(req).await;
assert_eq!(
status,
StatusCode::NOT_FOUND,
"User B should not be able to export User A's synthesis as Markdown"
);
}
// ═══════════════════════════════════════════════════════════════════════════
// PDF Export — GET /api/v1/syntheses/:id/export/pdf (4 tests)
// ═══════════════════════════════════════════════════════════════════════════
#[tokio::test]
async fn export_pdf_without_auth_returns_401() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let fake_id = uuid::Uuid::new_v4();
let req = unauthed_get(&format!("/api/v1/syntheses/{}/export/pdf", fake_id));
let response = app.raw_request(req).await;
assert_eq!(
response.status(),
StatusCode::UNAUTHORIZED,
"GET /syntheses/:id/export/pdf without auth should return 401"
);
}
#[tokio::test]
async fn export_pdf_returns_200_with_correct_content_type() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (user_id, session) = app
.create_authenticated_user("pdf-export@example.com")
.await;
let sections = sample_sections();
let synth_id = app
.insert_test_synthesis(user_id, "2026-W12", &sections)
.await;
let req = authed_get(
&format!("/api/v1/syntheses/{}/export/pdf", synth_id),
&session,
);
let (status, bytes, headers) = app.raw_request_bytes(req).await;
assert_eq!(
status,
StatusCode::OK,
"GET /syntheses/:id/export/pdf should return 200"
);
let content_type = headers
.get("content-type")
.expect("Should have Content-Type header")
.to_str()
.unwrap();
assert_eq!(
content_type, "application/pdf",
"Content-Type should be application/pdf"
);
let content_disposition = headers
.get("content-disposition")
.expect("Should have Content-Disposition header")
.to_str()
.unwrap();
assert!(
content_disposition.contains("attachment"),
"Content-Disposition should indicate attachment download"
);
assert!(
content_disposition.contains(".pdf"),
"Content-Disposition filename should end with .pdf"
);
// Also verify PDF is non-empty
assert!(
!bytes.is_empty(),
"PDF response body should not be empty"
);
}
#[tokio::test]
async fn export_pdf_body_starts_with_pdf_magic_bytes() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (user_id, session) = app
.create_authenticated_user("pdf-magic@example.com")
.await;
let sections = sample_sections();
let synth_id = app
.insert_test_synthesis(user_id, "2026-W12", &sections)
.await;
let req = authed_get(
&format!("/api/v1/syntheses/{}/export/pdf", synth_id),
&session,
);
let (status, bytes, _headers) = app.raw_request_bytes(req).await;
assert_eq!(status, StatusCode::OK);
assert!(
bytes.starts_with(b"%PDF"),
"PDF body should start with %PDF magic bytes, got first 4 bytes: {:?}",
&bytes[..std::cmp::min(4, bytes.len())]
);
}
#[tokio::test]
async fn export_pdf_for_non_owned_synthesis_returns_404() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (user_a_id, _session_a) = app
.create_authenticated_user("pdf-owner-a@example.com")
.await;
let (_user_b_id, session_b) = app
.create_authenticated_user("pdf-owner-b@example.com")
.await;
// Insert a synthesis for User A
let sections = sample_sections();
let synth_id = app
.insert_test_synthesis(user_a_id, "2026-W12", &sections)
.await;
// User B tries to export User A's synthesis as PDF
let req = authed_get(
&format!("/api/v1/syntheses/{}/export/pdf", synth_id),
&session_b,
);
let (status, _bytes, _headers) = app.raw_request_bytes(req).await;
assert_eq!(
status,
StatusCode::NOT_FOUND,
"User B should not be able to export User A's synthesis as PDF"
);
}

@ -217,6 +217,34 @@ impl TestApp {
(status, text, headers) (status, text, headers)
} }
/// Send a raw `Request<Body>` through the router and return
/// (StatusCode, raw response bytes, and all response headers).
///
/// Useful for endpoints that return binary content (e.g. PDF export).
pub async fn raw_request_bytes(
&self,
req: Request<Body>,
) -> (StatusCode, Vec<u8>, axum::http::HeaderMap) {
let response = self
.router
.clone()
.oneshot(req)
.await
.expect("Failed to send raw request");
let status = response.status();
let headers = response.headers().clone();
let bytes = response
.into_body()
.collect()
.await
.expect("Failed to read response body")
.to_bytes()
.to_vec();
(status, bytes, headers)
}
/// Send a POST request *without* the CSRF header (to test CSRF rejection). /// Send a POST request *without* the CSRF header (to test CSRF rejection).
pub async fn post_without_csrf( pub async fn post_without_csrf(
&self, &self,

@ -0,0 +1,194 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
describe('Synthesis Export - File Download', () => {
const originalFetch = globalThis.fetch;
const originalLocation = window.location;
let createObjectURLMock: ReturnType<typeof vi.fn>;
let revokeObjectURLMock: ReturnType<typeof vi.fn>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let appendChildSpy: any;
let clickedLinks: { href: string; download: string }[];
beforeEach(() => {
vi.resetModules();
clickedLinks = [];
// Mock URL.createObjectURL / revokeObjectURL
createObjectURLMock = vi.fn().mockReturnValue('blob:http://localhost/fake-blob-url');
revokeObjectURLMock = vi.fn();
globalThis.URL.createObjectURL = createObjectURLMock;
globalThis.URL.revokeObjectURL = revokeObjectURLMock;
// Mock document.body.appendChild to capture the anchor click
appendChildSpy = vi.spyOn(document.body, 'appendChild').mockImplementation((node) => {
if (node instanceof HTMLAnchorElement) {
// Override click to just record it
node.click = () => {
clickedLinks.push({ href: node.href, download: node.download });
};
}
return node;
});
// Mock window.location for 401 redirect
Object.defineProperty(window, 'location', {
value: { href: '' },
writable: true,
});
});
afterEach(() => {
globalThis.fetch = originalFetch;
appendChildSpy.mockRestore();
Object.defineProperty(window, 'location', {
value: originalLocation,
writable: true,
});
});
it('exportMarkdown triggers a file download with correct filename', async () => {
const markdownContent = '# Synthese\n\nContenu test';
const blob = new Blob([markdownContent], { type: 'text/markdown' });
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: new Headers({
'Content-Disposition': 'attachment; filename="synthese-semaine-12.md"',
}),
blob: () => Promise.resolve(blob),
});
const { synthesesApi } = await import('~/api/syntheses');
await synthesesApi.exportMarkdown('test-id');
// fetch was called with correct URL
expect(globalThis.fetch).toHaveBeenCalledWith(
'/api/v1/syntheses/test-id/export/markdown',
expect.objectContaining({
method: 'GET',
credentials: 'same-origin',
}),
);
// Blob was turned into an object URL
expect(createObjectURLMock).toHaveBeenCalledWith(blob);
// An anchor was clicked to trigger download
expect(clickedLinks).toHaveLength(1);
expect(clickedLinks[0].download).toBe('synthese-semaine-12.md');
// Object URL was revoked
expect(revokeObjectURLMock).toHaveBeenCalledWith('blob:http://localhost/fake-blob-url');
});
it('exportPdf triggers a file download with correct filename', async () => {
const pdfContent = new Uint8Array([0x25, 0x50, 0x44, 0x46]); // %PDF header
const blob = new Blob([pdfContent], { type: 'application/pdf' });
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: new Headers({
'Content-Disposition': 'attachment; filename="synthese-semaine-12.pdf"',
}),
blob: () => Promise.resolve(blob),
});
const { synthesesApi } = await import('~/api/syntheses');
await synthesesApi.exportPdf('test-id');
expect(globalThis.fetch).toHaveBeenCalledWith(
'/api/v1/syntheses/test-id/export/pdf',
expect.objectContaining({
method: 'GET',
}),
);
expect(createObjectURLMock).toHaveBeenCalledWith(blob);
expect(clickedLinks).toHaveLength(1);
expect(clickedLinks[0].download).toBe('synthese-semaine-12.pdf');
expect(revokeObjectURLMock).toHaveBeenCalled();
});
it('uses fallback filename when Content-Disposition header is absent', async () => {
const blob = new Blob(['content'], { type: 'text/markdown' });
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: new Headers({}),
blob: () => Promise.resolve(blob),
});
const { synthesesApi } = await import('~/api/syntheses');
await synthesesApi.exportMarkdown('abc-123');
expect(clickedLinks).toHaveLength(1);
expect(clickedLinks[0].download).toBe('synthese-abc-123.md');
});
it('throws an error on non-OK response', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
headers: new Headers({}),
json: () => Promise.resolve({ error: 'Internal Server Error' }),
});
const { synthesesApi } = await import('~/api/syntheses');
await expect(synthesesApi.exportMarkdown('test-id')).rejects.toEqual(
expect.objectContaining({
status: 500,
message: 'Internal Server Error',
}),
);
// No download should have been triggered
expect(createObjectURLMock).not.toHaveBeenCalled();
});
it('redirects to /login on 401 response', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 401,
headers: new Headers({}),
json: () => Promise.resolve({ error: 'Unauthorized' }),
});
const { synthesesApi } = await import('~/api/syntheses');
await expect(synthesesApi.exportPdf('test-id')).rejects.toEqual(
expect.objectContaining({
status: 401,
}),
);
expect(window.location.href).toBe('/login');
});
it('sendEmail calls POST with correct payload', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 204,
json: () => Promise.reject(new Error('No content')),
});
const { synthesesApi } = await import('~/api/syntheses');
await synthesesApi.sendEmail('synth-123', 'user@example.com');
expect(globalThis.fetch).toHaveBeenCalledWith(
'/api/v1/syntheses/synth-123/send-email',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ email: 'user@example.com' }),
headers: expect.objectContaining({
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
}),
}),
);
});
});

@ -1,8 +1,62 @@
import { api } from './client'; import { api } from './client';
import type { SynthesisListItem, Synthesis, GenerateResponse } from '~/types'; import type { SynthesisListItem, Synthesis, GenerateResponse, SendEmailRequest } from '~/types';
const API_BASE = '/api/v1'; const API_BASE = '/api/v1';
/**
* Trigger a file download from a fetch Response.
* Reads the blob, creates a temporary object URL, clicks a hidden anchor, then cleans up.
*/
async function triggerDownload(response: Response, fallbackFilename: string): Promise<void> {
const blob = await response.blob();
// Try to extract filename from Content-Disposition header
const disposition = response.headers.get('Content-Disposition');
let filename = fallbackFilename;
if (disposition) {
const match = disposition.match(/filename="?([^";\n]+)"?/);
if (match) {
filename = match[1];
}
}
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
/**
* Perform an authenticated GET request that expects a binary/file response.
* Throws an ApiError-shaped object on failure.
*/
async function fetchFile(path: string): Promise<Response> {
const response = await fetch(`${API_BASE}${path}`, {
method: 'GET',
headers: {
'X-Requested-With': 'XMLHttpRequest',
},
credentials: 'same-origin',
});
if (!response.ok) {
if (response.status === 401) {
window.location.href = '/login';
}
const errorBody = await response.json().catch(() => ({ error: 'Unknown error' }));
throw {
status: response.status,
message: errorBody.error || errorBody.message || `HTTP ${response.status}`,
};
}
return response;
}
export const synthesesApi = { export const synthesesApi = {
list: (limit = 50, offset = 0): Promise<SynthesisListItem[]> => list: (limit = 50, offset = 0): Promise<SynthesisListItem[]> =>
api.get<SynthesisListItem[]>(`/syntheses?limit=${limit}&offset=${offset}`), api.get<SynthesisListItem[]>(`/syntheses?limit=${limit}&offset=${offset}`),
@ -18,4 +72,17 @@ export const synthesesApi = {
progressUrl: (jobId: string): string => progressUrl: (jobId: string): string =>
`${API_BASE}/syntheses/generate/${jobId}/progress`, `${API_BASE}/syntheses/generate/${jobId}/progress`,
sendEmail: (id: string, email: string): Promise<void> =>
api.post<void>(`/syntheses/${id}/send-email`, { email } satisfies SendEmailRequest),
exportMarkdown: async (id: string): Promise<void> => {
const response = await fetchFile(`/syntheses/${id}/export/markdown`);
await triggerDownload(response, `synthese-${id}.md`);
},
exportPdf: async (id: string): Promise<void> => {
const response = await fetchFile(`/syntheses/${id}/export/pdf`);
await triggerDownload(response, `synthese-${id}.pdf`);
},
}; };

@ -98,6 +98,22 @@ const fr = {
'synthesis.deleteError': 'Erreur lors de la suppression.', 'synthesis.deleteError': 'Erreur lors de la suppression.',
'synthesis.backToHome': 'Retour a l\'accueil', 'synthesis.backToHome': 'Retour a l\'accueil',
// Synthesis - Email
'synthesis.email.title': 'Envoyer par email',
'synthesis.email.placeholder': 'adresse@email.com',
'synthesis.email.send': 'Envoyer par email',
'synthesis.email.sendSelf': 'S\'envoyer a soi-meme',
'synthesis.email.sending': 'Envoi en cours...',
'synthesis.email.success': 'L\'email a ete envoye avec succes !',
'synthesis.email.error': 'Erreur lors de l\'envoi de l\'email.',
// Synthesis - Export
'synthesis.export.title': 'Exporter',
'synthesis.export.markdown': 'Exporter en Markdown',
'synthesis.export.pdf': 'Exporter en PDF',
'synthesis.export.downloading': 'Telechargement...',
'synthesis.export.error': 'Erreur lors de l\'export.',
// Settings // Settings
'settings.title': 'Parametres de generation', 'settings.title': 'Parametres de generation',
'settings.theme': 'Theme de la recherche', 'settings.theme': 'Theme de la recherche',

@ -6,8 +6,9 @@ import {
For, For,
} from 'solid-js'; } from 'solid-js';
import { useParams, useNavigate, A } from '@solidjs/router'; import { useParams, useNavigate, A } from '@solidjs/router';
import { ArrowLeft, ExternalLink, Trash2, AlertTriangle } from 'lucide-solid'; import { ArrowLeft, ExternalLink, Trash2, AlertTriangle, Mail, Send, FileDown } from 'lucide-solid';
import { useI18n } from '~/i18n'; import { useI18n } from '~/i18n';
import { useAuth } from '~/contexts/AuthContext';
import { synthesesApi } from '~/api/syntheses'; import { synthesesApi } from '~/api/syntheses';
import { isApiError } from '~/types'; import { isApiError } from '~/types';
import type { Synthesis, NewsItem as NewsItemType } from '~/types'; import type { Synthesis, NewsItem as NewsItemType } from '~/types';
@ -54,6 +55,7 @@ const Section: Component<{ title: string; items: NewsItemType[] }> = (props) =>
const SynthesisDetail: Component = () => { const SynthesisDetail: Component = () => {
const { t } = useI18n(); const { t } = useI18n();
const { user } = useAuth();
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
@ -63,7 +65,24 @@ const SynthesisDetail: Component = () => {
const [showDeleteConfirm, setShowDeleteConfirm] = createSignal(false); const [showDeleteConfirm, setShowDeleteConfirm] = createSignal(false);
const [isDeleting, setIsDeleting] = createSignal(false); const [isDeleting, setIsDeleting] = createSignal(false);
// Email state
const [email, setEmail] = createSignal('');
const [sendingEmail, setSendingEmail] = createSignal(false);
const [emailSuccess, setEmailSuccess] = createSignal(false);
const [emailError, setEmailError] = createSignal<string | null>(null);
// Export state
const [exportingMarkdown, setExportingMarkdown] = createSignal(false);
const [exportingPdf, setExportingPdf] = createSignal(false);
const [exportError, setExportError] = createSignal<string | null>(null);
onMount(async () => { onMount(async () => {
// Pre-fill email from authenticated user
const currentUser = user();
if (currentUser?.email) {
setEmail(currentUser.email);
}
try { try {
const data = await synthesesApi.get(params.id); const data = await synthesesApi.get(params.id);
setSynthesis(data); setSynthesis(data);
@ -99,6 +118,69 @@ const SynthesisDetail: Component = () => {
} }
}; };
const handleSendEmail = async () => {
const synth = synthesis();
if (!synth || !email()) return;
setSendingEmail(true);
setEmailError(null);
setEmailSuccess(false);
try {
await synthesesApi.sendEmail(synth.id, email());
setEmailSuccess(true);
setTimeout(() => setEmailSuccess(false), 5000);
} catch (err) {
if (isApiError(err)) {
setEmailError(err.message);
} else {
setEmailError(t('synthesis.email.error'));
}
} finally {
setSendingEmail(false);
}
};
const handleExportMarkdown = async () => {
const synth = synthesis();
if (!synth) return;
setExportingMarkdown(true);
setExportError(null);
try {
await synthesesApi.exportMarkdown(synth.id);
} catch (err) {
if (isApiError(err)) {
setExportError(err.message);
} else {
setExportError(t('synthesis.export.error'));
}
} finally {
setExportingMarkdown(false);
}
};
const handleExportPdf = async () => {
const synth = synthesis();
if (!synth) return;
setExportingPdf(true);
setExportError(null);
try {
await synthesesApi.exportPdf(synth.id);
} catch (err) {
if (isApiError(err)) {
setExportError(err.message);
} else {
setExportError(t('synthesis.export.error'));
}
} finally {
setExportingPdf(false);
}
};
return ( return (
<Show when={!loading()} fallback={<LoadingSpinner />}> <Show when={!loading()} fallback={<LoadingSpinner />}>
<Show <Show
@ -171,6 +253,105 @@ const SynthesisDetail: Component = () => {
{t('synthesis.generatedAt', { date: formatDateLong(synth().created_at) })} {t('synthesis.generatedAt', { date: formatDateLong(synth().created_at) })}
</span> </span>
</div> </div>
{/* Email section */}
<div class="mt-6 bg-white p-4 rounded-lg shadow-sm border border-gray-200">
<div class="flex flex-col sm:flex-row items-center gap-4">
<div class="flex-1 w-full">
<label for="email-input" class="sr-only">
{t('synthesis.email.title')}
</label>
<div class="relative rounded-md shadow-sm">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Mail class="h-5 w-5 text-gray-400" />
</div>
<input
type="email"
id="email-input"
class="focus:ring-indigo-500 focus:border-indigo-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md py-2 border"
placeholder={t('synthesis.email.placeholder')}
value={email()}
onInput={(e) => setEmail(e.currentTarget.value)}
/>
</div>
</div>
<button
onClick={handleSendEmail}
disabled={sendingEmail() || !email()}
class="w-full sm:w-auto inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Show
when={!sendingEmail()}
fallback={
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2" />
}
>
<Send class="h-4 w-4 mr-2" />
</Show>
{sendingEmail() ? t('synthesis.email.sending') : t('synthesis.email.send')}
</button>
</div>
{/* Email success message */}
<Show when={emailSuccess()}>
<div class="mt-3 text-sm font-medium text-green-600 bg-green-50 p-3 rounded-md border border-green-200">
{t('synthesis.email.success')}
</div>
</Show>
{/* Email error message */}
<Show when={emailError()}>
<div class="mt-3 text-sm font-medium text-red-600 bg-red-50 p-3 rounded-md border border-red-200">
{emailError()}
</div>
</Show>
</div>
{/* Export section */}
<div class="mt-4 bg-white p-4 rounded-lg shadow-sm border border-gray-200">
<h3 class="text-sm font-medium text-gray-700 mb-3">
{t('synthesis.export.title')}
</h3>
<div class="flex flex-col sm:flex-row gap-3">
<button
onClick={handleExportMarkdown}
disabled={exportingMarkdown()}
class="inline-flex items-center justify-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Show
when={!exportingMarkdown()}
fallback={
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-500 mr-2" />
}
>
<FileDown class="h-4 w-4 mr-2" />
</Show>
{exportingMarkdown() ? t('synthesis.export.downloading') : t('synthesis.export.markdown')}
</button>
<button
onClick={handleExportPdf}
disabled={exportingPdf()}
class="inline-flex items-center justify-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Show
when={!exportingPdf()}
fallback={
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-500 mr-2" />
}
>
<FileDown class="h-4 w-4 mr-2" />
</Show>
{exportingPdf() ? t('synthesis.export.downloading') : t('synthesis.export.pdf')}
</button>
</div>
{/* Export error message */}
<Show when={exportError()}>
<div class="mt-3 text-sm font-medium text-red-600 bg-red-50 p-3 rounded-md border border-red-200">
{exportError()}
</div>
</Show>
</div>
</div> </div>
{/* Sections */} {/* Sections */}

@ -125,6 +125,10 @@ export interface GenerateResponse {
job_id: string; job_id: string;
} }
export interface SendEmailRequest {
email: string;
}
export interface ProgressEvent { export interface ProgressEvent {
step: string; step: string;
message: string; message: string;

Loading…
Cancel
Save