From 990e384d80144317867acb19103d86c5d21702f2 Mon Sep 17 00:00:00 2001 From: David Viejo Date: Fri, 20 Mar 2026 08:36:48 +0100 Subject: [PATCH] feat(email): add open and click tracking for transactional emails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Inject 1x1 tracking pixel for open tracking - Rewrite links through click tracking endpoint with 302 redirect - New email_events and email_links tables with cascade FK - TrackingService: HTML transformation, event recording, counters - Public endpoints: GET /emails/{id}/track/open (GIF pixel), GET /emails/{id}/track/click/{index} (redirect) - Authenticated endpoints: tracking summary, events list, links list - Plugin trait: configure_public_routes() for unauthenticated endpoints - Frontend: open/click columns in email list, tracking card in detail - 116 tests including full E2E flow (open → click → query → verify DB) --- CHANGELOG.md | 14 + Cargo.lock | 3 + crates/temps-core/src/plugin.rs | 20 +- crates/temps-email/Cargo.toml | 3 + crates/temps-email/src/handlers/emails.rs | 14 + crates/temps-email/src/handlers/mod.rs | 22 +- crates/temps-email/src/handlers/tracking.rs | 390 ++++++++++ .../src/handlers/tracking_tests.rs | 733 ++++++++++++++++++ crates/temps-email/src/handlers/types.rs | 23 +- crates/temps-email/src/lib.rs | 4 +- crates/temps-email/src/plugin.rs | 27 +- .../temps-email/src/services/email_service.rs | 59 +- crates/temps-email/src/services/mod.rs | 4 + .../src/services/tracking_service.rs | 508 ++++++++++++ .../tracking_service_integration_tests.rs | 414 ++++++++++ crates/temps-entities/src/email_events.rs | 37 + crates/temps-entities/src/email_links.rs | 33 + crates/temps-entities/src/emails.rs | 6 + crates/temps-entities/src/lib.rs | 2 + .../m20260320_000001_add_email_tracking.rs | 206 +++++ crates/temps-migrations/src/migration/mod.rs | 2 + web/src/api/client/types.gen.ts | 32 + web/src/components/email/EmailsSentList.tsx | 30 + web/src/pages/EmailDetail.tsx | 49 ++ 24 files changed, 2623 insertions(+), 12 deletions(-) create mode 100644 crates/temps-email/src/handlers/tracking.rs create mode 100644 crates/temps-email/src/handlers/tracking_tests.rs create mode 100644 crates/temps-email/src/services/tracking_service.rs create mode 100644 crates/temps-email/src/services/tracking_service_integration_tests.rs create mode 100644 crates/temps-entities/src/email_events.rs create mode 100644 crates/temps-entities/src/email_links.rs create mode 100644 crates/temps-migrations/src/migration/m20260320_000001_add_email_tracking.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 73936fdf..0b799c94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Email open tracking: 1x1 transparent tracking pixel injected before `` in outgoing HTML emails when `track_opens: true` is set on the send API; pixel hits `GET /api/emails/{id}/track/open` which returns a GIF and records the open event with IP and user-agent +- Email click tracking: all `` links in HTML emails are rewritten to route through `GET /api/emails/{id}/track/click/{link_index}` when `track_clicks: true` is set; the endpoint records the click event and 302-redirects to the original URL; `mailto:`, `tel:`, `#anchor`, and `javascript:` links are preserved unchanged +- `email_events` table for granular tracking event storage (event_type, link_url, link_index, ip_address, user_agent) with foreign key cascade to `emails` +- `email_links` table mapping link indices to original URLs with per-link click counts +- `track_opens`, `track_clicks`, `open_count`, `click_count`, `first_opened_at`, `first_clicked_at` columns on the `emails` table +- `TrackingService` in `temps-email` crate: HTML transformation (pixel injection + link rewriting), event recording, counter management, and link/event queries +- Authenticated tracking data endpoints: `GET /api/emails/{id}/tracking` (summary with unique open/click counts), `GET /api/emails/{id}/tracking/events` (filterable by event_type), `GET /api/emails/{id}/tracking/links` (per-link click stats) +- `configure_public_routes()` on the `TempsPlugin` trait for unauthenticated endpoints (tracking pixel and click redirect), served under `/api` without auth middleware +- `track_opens` and `track_clicks` fields on the `POST /api/emails` send API request body (default: false) +- Open/click count columns in the Sent Emails table (frontend) with eye and click icons +- Tracking stats card on the Email Detail page showing open count, click count, and first-event timestamps +- 116 tests: 12 tracking service integration tests, 14 HTTP handler tests (tower::oneshot), including a full E2E flow test (send → open pixel → click redirect → query tracking summary → verify DB state) + ## [0.0.6] - 2026-03-19 ### Added diff --git a/Cargo.lock b/Cargo.lock index c856b061..6a3c3c8c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11028,6 +11028,8 @@ dependencies = [ "chrono", "futures", "hickory-resolver 0.24.4", + "http 1.3.1", + "http-body-util", "reqwest", "sea-orm", "serde", @@ -11042,6 +11044,7 @@ dependencies = [ "thiserror 2.0.17", "tokio", "tokio-test", + "tower 0.5.2", "tracing", "urlencoding", "utoipa", diff --git a/crates/temps-core/src/plugin.rs b/crates/temps-core/src/plugin.rs index 7d24d811..10a1c1b3 100644 --- a/crates/temps-core/src/plugin.rs +++ b/crates/temps-core/src/plugin.rs @@ -393,6 +393,14 @@ pub trait TempsPlugin: Send + Sync { None } + /// Configure public HTTP routes that don't require authentication. + /// + /// These routes are served under /api but bypass auth middleware. + /// Use for tracking pixels, webhooks, and other public endpoints. + fn configure_public_routes(&self, _context: &PluginContext) -> Option { + None + } + /// Provide OpenAPI schema for this plugin's endpoints /// /// Return None if this plugin doesn't have API documentation. @@ -692,6 +700,7 @@ impl PluginManager { let plugin_context = self.context.create_plugin_context(); let mut api_router = Router::new(); + let mut public_router = Router::new(); // Collect routes from all plugins for plugin in &self.plugins { @@ -699,6 +708,10 @@ impl PluginManager { debug!("Adding routes for plugin: {}", plugin.name()); api_router = api_router.merge(plugin_routes.router); } + if let Some(public_routes) = plugin.configure_public_routes(&plugin_context) { + debug!("Adding public routes for plugin: {}", plugin.name()); + public_router = public_router.merge(public_routes.router); + } } // Collect and apply middleware from all plugins @@ -709,8 +722,11 @@ impl PluginManager { let _openapi_schema = self.build_unified_openapi()?; let docs_router = Router::new(); - // Combine everything - let app = Router::new().nest("/api", api_router).merge(docs_router); + // Combine everything: public routes under /api (no auth), then authenticated routes + let app = Router::new() + .nest("/api", public_router) + .nest("/api", api_router) + .merge(docs_router); Ok(app) } diff --git a/crates/temps-email/Cargo.toml b/crates/temps-email/Cargo.toml index 1d088c1b..1f586c05 100644 --- a/crates/temps-email/Cargo.toml +++ b/crates/temps-email/Cargo.toml @@ -60,3 +60,6 @@ check-if-email-exists = "0.11" tokio-test = "0.4" testcontainers = { workspace = true } temps-migrations = { path = "../temps-migrations" } +tower = { workspace = true } +http = { workspace = true } +http-body-util = { workspace = true } diff --git a/crates/temps-email/src/handlers/emails.rs b/crates/temps-email/src/handlers/emails.rs index d854f9a5..121cd79a 100644 --- a/crates/temps-email/src/handlers/emails.rs +++ b/crates/temps-email/src/handlers/emails.rs @@ -81,6 +81,8 @@ pub async fn send_email( text: request.text.clone(), headers: request.headers.clone(), tags: request.tags.clone(), + track_opens: request.track_opens.unwrap_or(false), + track_clicks: request.track_clicks.unwrap_or(false), }; let result = state.email_service.send(send_request).await.map_err(|e| { @@ -181,6 +183,12 @@ pub async fn list_emails( error_message: e.error_message, sent_at: e.sent_at.map(|dt| dt.to_rfc3339()), created_at: e.created_at.to_rfc3339(), + track_opens: e.track_opens, + track_clicks: e.track_clicks, + open_count: e.open_count, + click_count: e.click_count, + first_opened_at: e.first_opened_at.map(|dt| dt.to_rfc3339()), + first_clicked_at: e.first_clicked_at.map(|dt| dt.to_rfc3339()), }) .collect(); @@ -246,6 +254,12 @@ pub async fn get_email( error_message: email.error_message, sent_at: email.sent_at.map(|dt| dt.to_rfc3339()), created_at: email.created_at.to_rfc3339(), + track_opens: email.track_opens, + track_clicks: email.track_clicks, + open_count: email.open_count, + click_count: email.click_count, + first_opened_at: email.first_opened_at.map(|dt| dt.to_rfc3339()), + first_clicked_at: email.first_clicked_at.map(|dt| dt.to_rfc3339()), }; Ok(Json(response)) diff --git a/crates/temps-email/src/handlers/mod.rs b/crates/temps-email/src/handlers/mod.rs index 36c09266..4b722af3 100644 --- a/crates/temps-email/src/handlers/mod.rs +++ b/crates/temps-email/src/handlers/mod.rs @@ -4,6 +4,9 @@ mod audit; mod domains; mod emails; mod providers; +pub mod tracking; +#[cfg(test)] +mod tracking_tests; mod types; mod validation; @@ -13,13 +16,19 @@ use axum::Router; use std::sync::Arc; use utoipa::OpenApi; -/// Configure email routes +/// Configure email routes (authenticated) pub fn configure_routes() -> Router> { Router::new() .merge(providers::routes()) .merge(domains::routes()) .merge(emails::routes()) .merge(validation::routes()) + .merge(tracking::routes()) +} + +/// Configure public tracking routes (no auth required) +pub fn configure_public_routes() -> Router> { + tracking::public_routes() } #[derive(OpenApi)] @@ -45,6 +54,12 @@ pub fn configure_routes() -> Router> { emails::list_emails, emails::get_email, emails::get_email_stats, + // Tracking + tracking::track_open, + tracking::track_click, + tracking::get_email_tracking, + tracking::get_email_events, + tracking::get_email_links, // Validation validation::validate_email, ), @@ -71,6 +86,10 @@ pub fn configure_routes() -> Router> { types::EmailResponse, types::EmailStatsResponse, types::PaginatedEmailsResponse, + // Tracking types + tracking::EmailTrackingResponse, + tracking::TrackedLinkResponse, + tracking::TrackingEventResponse, // Validation types validation::ValidateEmailRequest, validation::ValidateEmailResponse, @@ -86,6 +105,7 @@ pub fn configure_routes() -> Router> { (name = "Email Providers", description = "Email provider management endpoints"), (name = "Email Domains", description = "Email domain management and verification"), (name = "Emails", description = "Email sending and retrieval"), + (name = "Email Tracking", description = "Email open and click tracking"), (name = "Email Validation", description = "Email address validation and verification") ) )] diff --git a/crates/temps-email/src/handlers/tracking.rs b/crates/temps-email/src/handlers/tracking.rs new file mode 100644 index 00000000..d2cefb49 --- /dev/null +++ b/crates/temps-email/src/handlers/tracking.rs @@ -0,0 +1,390 @@ +//! Email tracking handlers for open tracking (pixel) and click tracking (redirect) + +use std::sync::Arc; + +use axum::{ + extract::{Path, Query, State}, + http::{header, StatusCode}, + response::{IntoResponse, Redirect, Response}, + routing::get, + Json, Router, +}; +use serde::{Deserialize, Serialize}; +use temps_auth::{permission_guard, RequireAuth}; +use temps_core::{ + error_builder::{bad_request, internal_server_error, not_found}, + problemdetails::Problem, + RequestMetadata, +}; +use tracing::{error, warn}; +use utoipa::ToSchema; +use uuid::Uuid; + +use super::types::AppState; + +// 1x1 transparent GIF +const TRACKING_PIXEL: &[u8] = &[ + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00, 0xff, 0xff, 0xff, + 0x00, 0x00, 0x00, 0x21, 0xf9, 0x04, 0x01, 0x00, 0x00, 0x00, 0x00, 0x2c, 0x00, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x02, 0x44, 0x01, 0x00, 0x3b, +]; + +/// Configure tracking routes (public, no auth required) +pub fn public_routes() -> Router> { + Router::new() + .route("/emails/{email_id}/track/open", get(track_open)) + .route( + "/emails/{email_id}/track/click/{link_index}", + get(track_click), + ) +} + +/// Configure authenticated tracking data routes +pub fn routes() -> Router> { + Router::new() + .route("/emails/{id}/tracking", get(get_email_tracking)) + .route("/emails/{id}/tracking/events", get(get_email_events)) + .route("/emails/{id}/tracking/links", get(get_email_links)) +} + +/// Track email open - returns a 1x1 transparent GIF +/// +/// This endpoint is embedded as an tag in emails. +/// No authentication required - it's called by the email client. +#[utoipa::path( + tag = "Email Tracking", + get, + path = "/emails/{email_id}/track/open", + responses( + (status = 200, description = "1x1 transparent tracking pixel"), + (status = 404, description = "Email not found") + ), + params( + ("email_id" = String, Path, description = "Email ID (UUID)") + ) +)] +pub async fn track_open( + State(state): State>, + Path(email_id): Path, + axum::Extension(metadata): axum::Extension, +) -> Response { + let email_id = match Uuid::parse_str(&email_id) { + Ok(id) => id, + Err(_) => { + return ( + StatusCode::OK, + [(header::CONTENT_TYPE, "image/gif")], + TRACKING_PIXEL.to_vec(), + ) + .into_response(); + } + }; + + if let Err(e) = state + .tracking_service + .record_open( + email_id, + Some(metadata.ip_address.clone()), + Some(metadata.user_agent.clone()), + ) + .await + { + warn!("Failed to record open event for email {}: {}", email_id, e); + } + + // Always return the pixel, even if recording failed + ( + StatusCode::OK, + [ + (header::CONTENT_TYPE, "image/gif"), + (header::CACHE_CONTROL, "no-store, no-cache, must-revalidate"), + ], + TRACKING_PIXEL.to_vec(), + ) + .into_response() +} + +/// Track email link click - redirects to original URL +/// +/// This endpoint replaces original links in tracked emails. +/// No authentication required - it's called when the recipient clicks a link. +#[utoipa::path( + tag = "Email Tracking", + get, + path = "/emails/{email_id}/track/click/{link_index}", + responses( + (status = 302, description = "Redirect to original URL"), + (status = 404, description = "Link not found") + ), + params( + ("email_id" = String, Path, description = "Email ID (UUID)"), + ("link_index" = i32, Path, description = "Link index") + ) +)] +pub async fn track_click( + State(state): State>, + Path((email_id, link_index)): Path<(String, i32)>, + axum::Extension(metadata): axum::Extension, +) -> Response { + let email_id = match Uuid::parse_str(&email_id) { + Ok(id) => id, + Err(_) => { + return (StatusCode::BAD_REQUEST, "Invalid email ID").into_response(); + } + }; + + match state + .tracking_service + .record_click( + email_id, + link_index, + Some(metadata.ip_address.clone()), + Some(metadata.user_agent.clone()), + ) + .await + { + Ok(redirect_url) => Redirect::temporary(&redirect_url).into_response(), + Err(e) => { + warn!( + "Failed to record click for email {} link {}: {}", + email_id, link_index, e + ); + (StatusCode::NOT_FOUND, "Link not found").into_response() + } + } +} + +/// Email tracking summary +#[derive(Debug, Serialize, ToSchema)] +pub struct EmailTrackingResponse { + pub email_id: String, + pub track_opens: bool, + pub track_clicks: bool, + pub open_count: i32, + pub click_count: i32, + pub first_opened_at: Option, + pub first_clicked_at: Option, + pub unique_opens: u64, + pub unique_clicks: u64, + pub links: Vec, +} + +/// Tracked link with click count +#[derive(Debug, Serialize, ToSchema)] +pub struct TrackedLinkResponse { + pub link_index: i32, + pub original_url: String, + pub click_count: i32, +} + +/// Email tracking event +#[derive(Debug, Serialize, ToSchema)] +pub struct TrackingEventResponse { + pub id: i64, + pub event_type: String, + pub link_url: Option, + pub link_index: Option, + pub ip_address: Option, + pub user_agent: Option, + pub created_at: String, +} + +/// Get email tracking summary +#[utoipa::path( + tag = "Email Tracking", + get, + path = "/emails/{id}/tracking", + responses( + (status = 200, description = "Tracking summary", body = EmailTrackingResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Insufficient permissions"), + (status = 404, description = "Email not found") + ), + params( + ("id" = String, Path, description = "Email ID (UUID)") + ), + security(("bearer_auth" = [])) +)] +pub async fn get_email_tracking( + RequireAuth(auth): RequireAuth, + State(state): State>, + Path(id): Path, +) -> Result { + permission_guard!(auth, EmailsRead); + + let email_id = Uuid::parse_str(&id) + .map_err(|_| bad_request().detail("Invalid email ID format").build())?; + + let email = state.email_service.get(email_id).await.map_err(|e| { + error!("Failed to get email: {}", e); + not_found().detail("Email not found").build() + })?; + + let links = state + .tracking_service + .get_links(email_id) + .await + .map_err(|e| { + error!("Failed to get tracking links: {}", e); + internal_server_error() + .detail("Failed to get tracking data") + .build() + })?; + + let events = state + .tracking_service + .get_events(email_id, None) + .await + .map_err(|e| { + error!("Failed to get tracking events: {}", e); + internal_server_error() + .detail("Failed to get tracking data") + .build() + })?; + + // Count unique IPs for opens/clicks + let unique_opens = events + .iter() + .filter(|e| e.event_type == "open") + .filter_map(|e| e.ip_address.as_ref()) + .collect::>() + .len() as u64; + + let unique_clicks = events + .iter() + .filter(|e| e.event_type == "click") + .filter_map(|e| e.ip_address.as_ref()) + .collect::>() + .len() as u64; + + let response = EmailTrackingResponse { + email_id: email.id.to_string(), + track_opens: email.track_opens, + track_clicks: email.track_clicks, + open_count: email.open_count, + click_count: email.click_count, + first_opened_at: email.first_opened_at.map(|dt| dt.to_rfc3339()), + first_clicked_at: email.first_clicked_at.map(|dt| dt.to_rfc3339()), + unique_opens, + unique_clicks, + links: links + .into_iter() + .map(|l| TrackedLinkResponse { + link_index: l.link_index, + original_url: l.original_url, + click_count: l.click_count, + }) + .collect(), + }; + + Ok(Json(response)) +} + +/// Get email tracking events +#[utoipa::path( + tag = "Email Tracking", + get, + path = "/emails/{id}/tracking/events", + responses( + (status = 200, description = "Tracking events", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Insufficient permissions"), + (status = 404, description = "Email not found") + ), + params( + ("id" = String, Path, description = "Email ID (UUID)"), + ("event_type" = Option, Query, description = "Filter by event type (open, click)") + ), + security(("bearer_auth" = [])) +)] +pub async fn get_email_events( + RequireAuth(auth): RequireAuth, + State(state): State>, + Path(id): Path, + Query(query): Query, +) -> Result { + permission_guard!(auth, EmailsRead); + + let email_id = Uuid::parse_str(&id) + .map_err(|_| bad_request().detail("Invalid email ID format").build())?; + + let events = state + .tracking_service + .get_events(email_id, query.event_type.as_deref()) + .await + .map_err(|e| { + error!("Failed to get tracking events: {}", e); + internal_server_error() + .detail("Failed to get tracking events") + .build() + })?; + + let response: Vec = events + .into_iter() + .map(|e| TrackingEventResponse { + id: e.id, + event_type: e.event_type, + link_url: e.link_url, + link_index: e.link_index, + ip_address: e.ip_address, + user_agent: e.user_agent, + created_at: e.created_at.to_rfc3339(), + }) + .collect(); + + Ok(Json(response)) +} + +/// Get tracked links for an email +#[utoipa::path( + tag = "Email Tracking", + get, + path = "/emails/{id}/tracking/links", + responses( + (status = 200, description = "Tracked links", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Insufficient permissions"), + (status = 404, description = "Email not found") + ), + params( + ("id" = String, Path, description = "Email ID (UUID)") + ), + security(("bearer_auth" = [])) +)] +pub async fn get_email_links( + RequireAuth(auth): RequireAuth, + State(state): State>, + Path(id): Path, +) -> Result { + permission_guard!(auth, EmailsRead); + + let email_id = Uuid::parse_str(&id) + .map_err(|_| bad_request().detail("Invalid email ID format").build())?; + + let links = state + .tracking_service + .get_links(email_id) + .await + .map_err(|e| { + error!("Failed to get tracking links: {}", e); + internal_server_error() + .detail("Failed to get tracking links") + .build() + })?; + + let response: Vec = links + .into_iter() + .map(|l| TrackedLinkResponse { + link_index: l.link_index, + original_url: l.original_url, + click_count: l.click_count, + }) + .collect(); + + Ok(Json(response)) +} + +#[derive(Debug, Deserialize)] +pub struct EventsQuery { + pub event_type: Option, +} diff --git a/crates/temps-email/src/handlers/tracking_tests.rs b/crates/temps-email/src/handlers/tracking_tests.rs new file mode 100644 index 00000000..5c46d55f --- /dev/null +++ b/crates/temps-email/src/handlers/tracking_tests.rs @@ -0,0 +1,733 @@ +//! Integration tests for email tracking HTTP endpoints +//! +//! Tests the actual HTTP routes using tower::ServiceExt::oneshot. +//! Public endpoints (track/open, track/click) are tested without auth. +//! Authenticated endpoints (/tracking, /tracking/events, /tracking/links) are tested with auth middleware. + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use axum::middleware; + use axum::Router; + use http_body_util::BodyExt; + use sea_orm::{ActiveModelTrait, ActiveValue::Set, EntityTrait}; + use temps_auth::{AuthContext, Role}; + use temps_core::{AuditLogger, AuditOperation, RequestMetadata}; + use temps_database::test_utils::TestDatabase; + use temps_entities::{email_links, emails, users}; + use tower::ServiceExt; + use uuid::Uuid; + + use crate::handlers::tracking::{public_routes, routes}; + use crate::handlers::types::AppState; + use crate::services::{ + DomainService, EmailService, ProviderService, TrackingService, ValidationConfig, + ValidationService, + }; + + // ============================================ + // Test Helpers + // ============================================ + + struct MockAuditLogger; + + #[async_trait::async_trait] + impl AuditLogger for MockAuditLogger { + async fn create_audit_log(&self, _operation: &dyn AuditOperation) -> anyhow::Result<()> { + Ok(()) + } + } + + fn create_test_encryption_service() -> Arc { + let key = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + Arc::new(temps_core::EncryptionService::new(key).unwrap()) + } + + fn test_request_metadata() -> RequestMetadata { + RequestMetadata { + ip_address: "127.0.0.1".to_string(), + user_agent: "test-agent".to_string(), + headers: axum::http::HeaderMap::new(), + visitor_id_cookie: None, + session_id_cookie: None, + base_url: "http://localhost:3000".to_string(), + scheme: "http".to_string(), + host: "localhost".to_string(), + is_secure: false, + } + } + + fn test_user() -> users::Model { + users::Model { + id: 1, + name: "Test User".to_string(), + email: "test@example.com".to_string(), + password_hash: None, + email_verified: true, + email_verification_token: None, + email_verification_expires: None, + password_reset_token: None, + password_reset_expires: None, + deleted_at: None, + mfa_secret: None, + mfa_enabled: false, + mfa_recovery_codes: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + } + } + + async fn setup_test_env() -> (TestDatabase, Arc) { + let db = TestDatabase::with_migrations().await.unwrap(); + let encryption_service = create_test_encryption_service(); + let provider_service = Arc::new(ProviderService::new(db.db.clone(), encryption_service)); + let domain_service = Arc::new(DomainService::new(db.db.clone(), provider_service.clone())); + let tracking_service = Arc::new(TrackingService::new( + db.db.clone(), + "http://localhost:3000".to_string(), + )); + let email_service = Arc::new(EmailService::new( + db.db.clone(), + provider_service.clone(), + domain_service.clone(), + tracking_service.clone(), + )); + let validation_service = Arc::new(ValidationService::new(ValidationConfig::default())); + + let app_state = Arc::new(AppState { + provider_service, + domain_service, + email_service, + validation_service, + tracking_service, + audit_service: Arc::new(MockAuditLogger), + dns_provider_service: None, + }); + + (db, app_state) + } + + /// Build public routes with RequestMetadata middleware (no auth) + fn build_public_app(state: Arc) -> Router { + let metadata_middleware = middleware::from_fn( + |mut req: Request, next: axum::middleware::Next| async move { + req.extensions_mut().insert(test_request_metadata()); + next.run(req).await + }, + ); + + public_routes().layer(metadata_middleware).with_state(state) + } + + /// Build authenticated routes with auth + RequestMetadata middleware + fn build_authed_app(state: Arc) -> Router { + let auth_middleware = middleware::from_fn( + |mut req: Request, next: axum::middleware::Next| async move { + let auth_context = AuthContext::new_session(test_user(), Role::Admin); + req.extensions_mut().insert(auth_context); + req.extensions_mut().insert(test_request_metadata()); + next.run(req).await + }, + ); + + routes().layer(auth_middleware).with_state(state) + } + + async fn create_test_email( + db: &Arc, + track_opens: bool, + track_clicks: bool, + ) -> Uuid { + let email_id = Uuid::new_v4(); + let email = emails::ActiveModel { + id: Set(email_id), + from_address: Set("sender@test.com".to_string()), + to_addresses: Set(serde_json::json!(["recipient@test.com"])), + subject: Set("Test email".to_string()), + html_body: Set(Some("

Hello

".to_string())), + status: Set("sent".to_string()), + track_opens: Set(track_opens), + track_clicks: Set(track_clicks), + open_count: Set(0), + click_count: Set(0), + ..Default::default() + }; + email.insert(db.as_ref()).await.unwrap(); + email_id + } + + async fn create_test_links(db: &Arc, email_id: Uuid) { + for (idx, url) in ["https://example.com/page1", "https://example.com/page2"] + .iter() + .enumerate() + { + let link = email_links::ActiveModel { + email_id: Set(email_id), + link_index: Set(idx as i32), + original_url: Set(url.to_string()), + click_count: Set(0), + ..Default::default() + }; + link.insert(db.as_ref()).await.unwrap(); + } + } + + // ============================================ + // Public Endpoint Tests: Track Open + // ============================================ + + #[tokio::test] + async fn test_track_open_returns_gif_pixel() { + let (db, state) = setup_test_env().await; + let email_id = create_test_email(&db.db, true, false).await; + + let app = build_public_app(state); + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/emails/{}/track/open", email_id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers().get("content-type").unwrap(), "image/gif"); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + // GIF89a header + assert_eq!(&body[..6], b"GIF89a"); + + // Verify the open was recorded in the database + let email = emails::Entity::find_by_id(email_id) + .one(db.db.as_ref()) + .await + .unwrap() + .unwrap(); + assert_eq!(email.open_count, 1); + assert!(email.first_opened_at.is_some()); + } + + #[tokio::test] + async fn test_track_open_returns_gif_even_for_invalid_uuid() { + let (_db, state) = setup_test_env().await; + + let app = build_public_app(state); + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/emails/not-a-uuid/track/open") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + // Should still return 200 with GIF (never leak info about email existence) + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers().get("content-type").unwrap(), "image/gif"); + } + + #[tokio::test] + async fn test_track_open_does_not_increment_when_tracking_disabled() { + let (db, state) = setup_test_env().await; + let email_id = create_test_email(&db.db, false, false).await; + + let app = build_public_app(state); + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/emails/{}/track/open", email_id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + // Counter should NOT have been incremented + let email = emails::Entity::find_by_id(email_id) + .one(db.db.as_ref()) + .await + .unwrap() + .unwrap(); + assert_eq!(email.open_count, 0); + } + + #[tokio::test] + async fn test_track_open_sets_no_cache_headers() { + let (db, state) = setup_test_env().await; + let email_id = create_test_email(&db.db, true, false).await; + + let app = build_public_app(state); + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/emails/{}/track/open", email_id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + response.headers().get("cache-control").unwrap(), + "no-store, no-cache, must-revalidate" + ); + } + + // ============================================ + // Public Endpoint Tests: Track Click + // ============================================ + + #[tokio::test] + async fn test_track_click_redirects_to_original_url() { + let (db, state) = setup_test_env().await; + let email_id = create_test_email(&db.db, false, true).await; + create_test_links(&db.db, email_id).await; + + let app = build_public_app(state); + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/emails/{}/track/click/0", email_id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT); + assert_eq!( + response.headers().get("location").unwrap(), + "https://example.com/page1" + ); + + // Verify click was recorded + let email = emails::Entity::find_by_id(email_id) + .one(db.db.as_ref()) + .await + .unwrap() + .unwrap(); + assert_eq!(email.click_count, 1); + assert!(email.first_clicked_at.is_some()); + } + + #[tokio::test] + async fn test_track_click_second_link() { + let (db, state) = setup_test_env().await; + let email_id = create_test_email(&db.db, false, true).await; + create_test_links(&db.db, email_id).await; + + let app = build_public_app(state); + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/emails/{}/track/click/1", email_id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT); + assert_eq!( + response.headers().get("location").unwrap(), + "https://example.com/page2" + ); + } + + #[tokio::test] + async fn test_track_click_invalid_link_index_returns_404() { + let (db, state) = setup_test_env().await; + let email_id = create_test_email(&db.db, false, true).await; + // No links stored + + let app = build_public_app(state); + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/emails/{}/track/click/999", email_id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn test_track_click_invalid_uuid_returns_400() { + let (_db, state) = setup_test_env().await; + + let app = build_public_app(state); + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/emails/not-a-uuid/track/click/0") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + + // ============================================ + // Authenticated Endpoint Tests: Get Tracking Summary + // ============================================ + + #[tokio::test] + async fn test_get_email_tracking_returns_summary() { + let (db, state) = setup_test_env().await; + let email_id = create_test_email(&db.db, true, true).await; + create_test_links(&db.db, email_id).await; + + // Record some opens and clicks via the service directly + state + .tracking_service + .record_open( + email_id, + Some("1.1.1.1".to_string()), + Some("Chrome".to_string()), + ) + .await + .unwrap(); + state + .tracking_service + .record_open( + email_id, + Some("2.2.2.2".to_string()), + Some("Firefox".to_string()), + ) + .await + .unwrap(); + state + .tracking_service + .record_click(email_id, 0, Some("1.1.1.1".to_string()), None) + .await + .unwrap(); + + let app = build_authed_app(state); + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/emails/{}/tracking", email_id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + + assert_eq!(json["email_id"], email_id.to_string()); + assert_eq!(json["track_opens"], true); + assert_eq!(json["track_clicks"], true); + assert_eq!(json["open_count"], 2); + assert_eq!(json["click_count"], 1); + assert_eq!(json["unique_opens"], 2); // 2 different IPs + assert_eq!(json["unique_clicks"], 1); + assert!(json["first_opened_at"].is_string()); + assert!(json["first_clicked_at"].is_string()); + assert_eq!(json["links"].as_array().unwrap().len(), 2); + assert_eq!(json["links"][0]["click_count"], 1); + assert_eq!(json["links"][1]["click_count"], 0); + } + + #[tokio::test] + async fn test_get_email_tracking_invalid_uuid_returns_400() { + let (_db, state) = setup_test_env().await; + + let app = build_authed_app(state); + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/emails/not-a-uuid/tracking") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + + // ============================================ + // Authenticated Endpoint Tests: Get Tracking Events + // ============================================ + + #[tokio::test] + async fn test_get_email_events_returns_all_events() { + let (db, state) = setup_test_env().await; + let email_id = create_test_email(&db.db, true, true).await; + create_test_links(&db.db, email_id).await; + + // Record events + state + .tracking_service + .record_open( + email_id, + Some("1.1.1.1".to_string()), + Some("Chrome".to_string()), + ) + .await + .unwrap(); + state + .tracking_service + .record_click(email_id, 0, Some("2.2.2.2".to_string()), None) + .await + .unwrap(); + + let app = build_authed_app(state); + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/emails/{}/tracking/events", email_id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let events: Vec = serde_json::from_slice(&body).unwrap(); + + assert_eq!(events.len(), 2); + assert_eq!(events[0]["event_type"], "open"); + assert_eq!(events[0]["ip_address"], "1.1.1.1"); + assert_eq!(events[1]["event_type"], "click"); + assert_eq!(events[1]["ip_address"], "2.2.2.2"); + assert_eq!(events[1]["link_index"], 0); + assert_eq!(events[1]["link_url"], "https://example.com/page1"); + } + + #[tokio::test] + async fn test_get_email_events_filtered_by_type() { + let (db, state) = setup_test_env().await; + let email_id = create_test_email(&db.db, true, true).await; + create_test_links(&db.db, email_id).await; + + state + .tracking_service + .record_open(email_id, None, None) + .await + .unwrap(); + state + .tracking_service + .record_click(email_id, 0, None, None) + .await + .unwrap(); + state + .tracking_service + .record_open(email_id, None, None) + .await + .unwrap(); + + let app = build_authed_app(state); + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri(format!( + "/emails/{}/tracking/events?event_type=open", + email_id + )) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let events: Vec = serde_json::from_slice(&body).unwrap(); + + assert_eq!(events.len(), 2, "Should only return open events"); + assert!(events.iter().all(|e| e["event_type"] == "open")); + } + + // ============================================ + // Authenticated Endpoint Tests: Get Tracking Links + // ============================================ + + #[tokio::test] + async fn test_get_email_links_returns_tracked_links() { + let (db, state) = setup_test_env().await; + let email_id = create_test_email(&db.db, false, true).await; + create_test_links(&db.db, email_id).await; + + // Click link 0 twice + state + .tracking_service + .record_click(email_id, 0, None, None) + .await + .unwrap(); + state + .tracking_service + .record_click(email_id, 0, None, None) + .await + .unwrap(); + + let app = build_authed_app(state); + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/emails/{}/tracking/links", email_id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let links: Vec = serde_json::from_slice(&body).unwrap(); + + assert_eq!(links.len(), 2); + assert_eq!(links[0]["original_url"], "https://example.com/page1"); + assert_eq!(links[0]["click_count"], 2); + assert_eq!(links[1]["original_url"], "https://example.com/page2"); + assert_eq!(links[1]["click_count"], 0); + } + + // ============================================ + // Full E2E Flow: Send email with tracking -> open -> click -> verify + // ============================================ + + #[tokio::test] + async fn test_full_tracking_flow_open_then_click() { + let (db, state) = setup_test_env().await; + + // Step 1: Create email with both tracking enabled + let email_id = create_test_email(&db.db, true, true).await; + create_test_links(&db.db, email_id).await; + + // Step 2: Simulate email open (tracking pixel loaded) + let public_app = build_public_app(state.clone()); + let open_response = public_app + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/emails/{}/track/open", email_id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(open_response.status(), StatusCode::OK); + + // Step 3: Simulate link click + let public_app = build_public_app(state.clone()); + let click_response = public_app + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/emails/{}/track/click/0", email_id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(click_response.status(), StatusCode::TEMPORARY_REDIRECT); + assert_eq!( + click_response.headers().get("location").unwrap(), + "https://example.com/page1" + ); + + // Step 4: Query tracking summary via authenticated endpoint + let authed_app = build_authed_app(state.clone()); + let tracking_response = authed_app + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/emails/{}/tracking", email_id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(tracking_response.status(), StatusCode::OK); + + let body = tracking_response + .into_body() + .collect() + .await + .unwrap() + .to_bytes(); + let tracking: serde_json::Value = serde_json::from_slice(&body).unwrap(); + + assert_eq!(tracking["open_count"], 1); + assert_eq!(tracking["click_count"], 1); + assert_eq!(tracking["unique_opens"], 1); + assert_eq!(tracking["unique_clicks"], 1); + assert!(tracking["first_opened_at"].is_string()); + assert!(tracking["first_clicked_at"].is_string()); + + // Step 5: Query events via authenticated endpoint + let authed_app = build_authed_app(state.clone()); + let events_response = authed_app + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/emails/{}/tracking/events", email_id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(events_response.status(), StatusCode::OK); + + let body = events_response + .into_body() + .collect() + .await + .unwrap() + .to_bytes(); + let events: Vec = serde_json::from_slice(&body).unwrap(); + + assert_eq!(events.len(), 2); + assert_eq!(events[0]["event_type"], "open"); + assert_eq!(events[0]["ip_address"], "127.0.0.1"); // from RequestMetadata + assert_eq!(events[0]["user_agent"], "test-agent"); + assert_eq!(events[1]["event_type"], "click"); + assert_eq!(events[1]["link_url"], "https://example.com/page1"); + + // Step 6: Verify the database state directly + let email = emails::Entity::find_by_id(email_id) + .one(db.db.as_ref()) + .await + .unwrap() + .unwrap(); + assert_eq!(email.open_count, 1); + assert_eq!(email.click_count, 1); + assert!(email.first_opened_at.is_some()); + assert!(email.first_clicked_at.is_some()); + } +} diff --git a/crates/temps-email/src/handlers/types.rs b/crates/temps-email/src/handlers/types.rs index d0d965e6..0e2e16e2 100644 --- a/crates/temps-email/src/handlers/types.rs +++ b/crates/temps-email/src/handlers/types.rs @@ -1,7 +1,9 @@ //! Handler types for the email service use crate::providers::EmailProviderType; -use crate::services::{DomainService, EmailService, ProviderService, ValidationService}; +use crate::services::{ + DomainService, EmailService, ProviderService, TrackingService, ValidationService, +}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::Arc; @@ -15,6 +17,7 @@ pub struct AppState { pub domain_service: Arc, pub email_service: Arc, pub validation_service: Arc, + pub tracking_service: Arc, pub audit_service: Arc, /// DNS provider service for automatic DNS record setup pub dns_provider_service: Option>, @@ -281,6 +284,12 @@ pub struct SendEmailRequestBody { /// Tags for categorization #[schema(example = json!(["welcome", "onboarding"]))] pub tags: Option>, + /// Enable open tracking (tracking pixel injection). Defaults to false. + #[serde(default)] + pub track_opens: Option, + /// Enable click tracking (link rewriting). Defaults to false. + #[serde(default)] + pub track_clicks: Option, } #[derive(Debug, Serialize, ToSchema)] @@ -320,6 +329,18 @@ pub struct EmailResponse { pub sent_at: Option, #[schema(example = "2025-12-03T10:30:00Z")] pub created_at: String, + /// Whether open tracking is enabled + pub track_opens: bool, + /// Whether click tracking is enabled + pub track_clicks: bool, + /// Number of times the email was opened + pub open_count: i32, + /// Number of times links in the email were clicked + pub click_count: i32, + /// When the email was first opened + pub first_opened_at: Option, + /// When a link was first clicked + pub first_clicked_at: Option, } #[derive(Debug, Serialize, ToSchema)] diff --git a/crates/temps-email/src/lib.rs b/crates/temps-email/src/lib.rs index b0b804a3..2c620fdd 100644 --- a/crates/temps-email/src/lib.rs +++ b/crates/temps-email/src/lib.rs @@ -21,6 +21,6 @@ pub use errors::EmailError; pub use plugin::EmailPlugin; pub use providers::{EmailProvider, EmailProviderType}; pub use services::{ - DomainService, EmailService, ProviderService, ValidateEmailRequest, ValidateEmailResponse, - ValidationService, + DomainService, EmailService, ProviderService, TrackingService, ValidateEmailRequest, + ValidateEmailResponse, ValidationService, }; diff --git a/crates/temps-email/src/plugin.rs b/crates/temps-email/src/plugin.rs index 85ac45de..9c25f9a1 100644 --- a/crates/temps-email/src/plugin.rs +++ b/crates/temps-email/src/plugin.rs @@ -13,7 +13,8 @@ use utoipa::OpenApi as OpenApiTrait; use crate::handlers::{self, AppState, EmailApiDoc}; use crate::services::{ - DomainService, EmailService, ProviderService, ValidationConfig, ValidationService, + DomainService, EmailService, ProviderService, TrackingService, ValidationConfig, + ValidationService, }; use temps_dns::services::DnsProviderService; @@ -55,11 +56,19 @@ impl TempsPlugin for EmailPlugin { let domain_service = Arc::new(DomainService::new(db.clone(), provider_service.clone())); context.register_service(domain_service.clone()); - // Create EmailService + // Create TrackingService + // Use TEMPS_BASE_URL env var if set, otherwise default + let base_url = std::env::var("TEMPS_BASE_URL") + .unwrap_or_else(|_| "http://localhost:3000".to_string()); + let tracking_service = Arc::new(TrackingService::new(db.clone(), base_url)); + context.register_service(tracking_service.clone()); + + // Create EmailService with tracking support let email_service = Arc::new(EmailService::new( db.clone(), provider_service.clone(), domain_service.clone(), + tracking_service.clone(), )); context.register_service(email_service.clone()); @@ -79,6 +88,7 @@ impl TempsPlugin for EmailPlugin { domain_service, email_service, validation_service, + tracking_service, audit_service, dns_provider_service, }); @@ -93,7 +103,7 @@ impl TempsPlugin for EmailPlugin { // Get the AppState let app_state = context.require_service::(); - // Configure routes + // Configure authenticated routes let email_routes = handlers::configure_routes().with_state(app_state); Some(PluginRoutes { @@ -101,6 +111,17 @@ impl TempsPlugin for EmailPlugin { }) } + fn configure_public_routes(&self, context: &PluginContext) -> Option { + let app_state = context.require_service::(); + + // Public tracking routes (no auth required) + let tracking_routes = handlers::configure_public_routes().with_state(app_state); + + Some(PluginRoutes { + router: tracking_routes, + }) + } + fn openapi_schema(&self) -> Option { Some(::openapi()) } diff --git a/crates/temps-email/src/services/email_service.rs b/crates/temps-email/src/services/email_service.rs index 1102bc2d..8c998fb7 100644 --- a/crates/temps-email/src/services/email_service.rs +++ b/crates/temps-email/src/services/email_service.rs @@ -7,18 +7,19 @@ use sea_orm::{ }; use std::sync::Arc; use temps_entities::emails; -use tracing::{debug, info}; +use tracing::{debug, info, warn}; use uuid::Uuid; use crate::errors::EmailError; use crate::providers::SendEmailRequest as ProviderSendRequest; -use crate::services::{DomainService, ProviderService}; +use crate::services::{DomainService, ProviderService, TrackingService}; /// Service for sending and managing emails pub struct EmailService { db: Arc, provider_service: Arc, domain_service: Arc, + tracking_service: Arc, } /// Request to send an email @@ -36,6 +37,10 @@ pub struct SendEmailRequest { pub text: Option, pub headers: Option>, pub tags: Option>, + /// Enable open tracking (tracking pixel injection) + pub track_opens: bool, + /// Enable click tracking (link rewriting) + pub track_clicks: bool, } /// Response from sending an email @@ -62,11 +67,13 @@ impl EmailService { db: Arc, provider_service: Arc, domain_service: Arc, + tracking_service: Arc, ) -> Self { Self { db, provider_service, domain_service, + tracking_service, } } @@ -94,6 +101,22 @@ impl EmailService { // Generate email ID let email_id = Uuid::new_v4(); + // Apply tracking transformations if enabled + let track_opens = request.track_opens; + let track_clicks = request.track_clicks; + let mut tracked_html = request.html.clone(); + let mut extracted_links = Vec::new(); + + if let Some(html) = &request.html { + if track_opens || track_clicks { + let transform_result = + self.tracking_service + .transform_html(email_id, html, track_opens, track_clicks); + tracked_html = Some(transform_result.html); + extracted_links = transform_result.links; + } + } + // Create email record - always store for visualization let email = emails::ActiveModel { id: Set(email_id), @@ -123,11 +146,29 @@ impl EmailService { .as_ref() .map(|v| serde_json::to_value(v).unwrap())), status: Set("queued".to_string()), + track_opens: Set(track_opens), + track_clicks: Set(track_clicks), + open_count: Set(0), + click_count: Set(0), ..Default::default() }; let email_model = email.insert(self.db.as_ref()).await?; + // Store extracted links for click tracking + if !extracted_links.is_empty() { + if let Err(e) = self + .tracking_service + .store_links(email_id, &extracted_links) + .await + { + warn!( + "Failed to store tracking links for email {}: {}", + email_id, e + ); + } + } + // If no domain configured, capture email without sending (Mailhog-like behavior) let domain = match domain { Some(d) => d, @@ -249,7 +290,7 @@ impl EmailService { bcc: request.bcc, reply_to: request.reply_to, subject: request.subject, - html: request.html, + html: tracked_html, text: request.text, headers: request.headers, }; @@ -397,6 +438,7 @@ mod tests { use super::*; use crate::providers::{EmailProviderType, SesCredentials}; use crate::services::provider_service::{CreateProviderRequest, ProviderCredentials}; + use crate::services::TrackingService; use temps_core::EncryptionService; use temps_database::test_utils::TestDatabase; @@ -412,10 +454,15 @@ mod tests { let encryption_service = create_test_encryption_service(); let provider_service = ProviderService::new(db.db.clone(), encryption_service); let domain_service = DomainService::new(db.db.clone(), Arc::new(provider_service.clone())); + let tracking_service = Arc::new(TrackingService::new( + db.db.clone(), + "http://localhost:3000".to_string(), + )); let email_service = EmailService::new( db.db.clone(), Arc::new(provider_service.clone()), Arc::new(domain_service.clone()), + tracking_service, ); (db, email_service, provider_service, domain_service) } @@ -487,6 +534,8 @@ mod tests { "value".to_string(), )])), tags: Some(vec!["tag1".to_string(), "tag2".to_string()]), + track_opens: false, + track_clicks: false, }; assert_eq!(request.from, "sender@example.com"); @@ -703,6 +752,8 @@ mod tests { text: None, headers: None, tags: None, + track_opens: false, + track_clicks: false, }; let result = email_service.send(request).await; @@ -730,6 +781,8 @@ mod tests { text: None, headers: None, tags: None, + track_opens: false, + track_clicks: false, }; let result = email_service.send(request).await; diff --git a/crates/temps-email/src/services/mod.rs b/crates/temps-email/src/services/mod.rs index 66cd5d9c..c23ec85b 100644 --- a/crates/temps-email/src/services/mod.rs +++ b/crates/temps-email/src/services/mod.rs @@ -3,6 +3,9 @@ mod domain_service; mod email_service; mod provider_service; +mod tracking_service; +#[cfg(test)] +mod tracking_service_integration_tests; mod validation_service; pub use domain_service::{CreateDomainRequest, DomainService, DomainWithDnsRecords}; @@ -12,6 +15,7 @@ pub use email_service::{ pub use provider_service::{ CreateProviderRequest, ProviderCredentials, ProviderService, TestEmailResult, }; +pub use tracking_service::{ExtractedLink, TrackingEvent, TrackingService, TransformResult}; pub use validation_service::{ MiscResult, MxResult, ProxyConfig, ReachabilityStatus, SmtpResult, SyntaxResult, ValidateEmailRequest, ValidateEmailResponse, ValidationConfig, ValidationService, diff --git a/crates/temps-email/src/services/tracking_service.rs b/crates/temps-email/src/services/tracking_service.rs new file mode 100644 index 00000000..45da3ee9 --- /dev/null +++ b/crates/temps-email/src/services/tracking_service.rs @@ -0,0 +1,508 @@ +//! Email tracking service for open tracking (pixel) and click tracking (link rewriting) + +use chrono::Utc; +use sea_orm::{ + ActiveModelTrait, ActiveValue::Set, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, +}; +use std::sync::Arc; +use temps_entities::{email_events, email_links, emails}; +use tracing::debug; +use uuid::Uuid; + +use crate::errors::EmailError; + +/// Service for email tracking (opens, clicks) +pub struct TrackingService { + db: Arc, + /// Base URL for tracking endpoints (e.g., "https://app.example.com") + base_url: String, +} + +/// Result of transforming HTML for tracking +#[derive(Debug, Clone)] +pub struct TransformResult { + /// Transformed HTML with tracking pixel and rewritten links + pub html: String, + /// Extracted links with their indices + pub links: Vec, +} + +/// A link extracted during HTML transformation +#[derive(Debug, Clone)] +pub struct ExtractedLink { + pub index: i32, + pub original_url: String, +} + +/// Event recorded for tracking +#[derive(Debug, Clone)] +pub struct TrackingEvent { + pub email_id: Uuid, + pub event_type: String, + pub link_url: Option, + pub link_index: Option, + pub ip_address: Option, + pub user_agent: Option, +} + +impl TrackingService { + pub fn new(db: Arc, base_url: String) -> Self { + Self { db, base_url } + } + + /// Transform HTML body for tracking: inject open pixel + rewrite links + pub fn transform_html( + &self, + email_id: Uuid, + html: &str, + track_opens: bool, + track_clicks: bool, + ) -> TransformResult { + let mut result_html = html.to_string(); + let mut links = Vec::new(); + + // Rewrite links for click tracking + if track_clicks { + let (rewritten, extracted) = self.rewrite_links(email_id, &result_html); + result_html = rewritten; + links = extracted; + } + + // Inject tracking pixel for open tracking + if track_opens { + result_html = self.inject_tracking_pixel(email_id, &result_html); + } + + TransformResult { + html: result_html, + links, + } + } + + /// Inject a 1x1 transparent tracking pixel before or at end of HTML + fn inject_tracking_pixel(&self, email_id: Uuid, html: &str) -> String { + let pixel_url = format!("{}/api/emails/{}/track/open", self.base_url, email_id); + let pixel_tag = format!( + r#""#, + pixel_url + ); + + // Insert before if present, otherwise append + if let Some(pos) = html.to_lowercase().rfind("") { + let mut result = html.to_string(); + result.insert_str(pos, &pixel_tag); + result + } else { + format!("{}{}", html, pixel_tag) + } + } + + /// Rewrite all
links to go through the click tracking endpoint + fn rewrite_links(&self, email_id: Uuid, html: &str) -> (String, Vec) { + let mut links = Vec::new(); + let mut result = String::with_capacity(html.len() + 256); + let mut link_index: i32 = 0; + + let mut remaining = html; + while let Some(href_start) = find_href_start(remaining) { + // Copy everything before href=" + result.push_str(&remaining[..href_start.offset]); + + let after_href = &remaining[href_start.offset + href_start.prefix_len..]; + + // Find the closing quote + if let Some(end_pos) = after_href.find(href_start.quote) { + let original_url = &after_href[..end_pos]; + + // Only track http/https links, skip mailto:, tel:, #, javascript: + if should_track_link(original_url) { + let tracking_url = format!( + "{}/api/emails/{}/track/click/{}", + self.base_url, email_id, link_index + ); + + links.push(ExtractedLink { + index: link_index, + original_url: original_url.to_string(), + }); + + result.push_str(&format!( + "href={}{}{}", + href_start.quote, tracking_url, href_start.quote + )); + link_index += 1; + } else { + // Keep original + result.push_str(&format!( + "href={}{}{}", + href_start.quote, original_url, href_start.quote + )); + } + + remaining = &after_href[end_pos + 1..]; + } else { + // Malformed href, copy as-is + result.push_str( + &remaining[href_start.offset..href_start.offset + href_start.prefix_len], + ); + remaining = after_href; + } + } + + // Copy the rest + result.push_str(remaining); + + (result, links) + } + + /// Store extracted links in the database + pub async fn store_links( + &self, + email_id: Uuid, + links: &[ExtractedLink], + ) -> Result<(), EmailError> { + for link in links { + let model = email_links::ActiveModel { + email_id: Set(email_id), + link_index: Set(link.index), + original_url: Set(link.original_url.clone()), + click_count: Set(0), + ..Default::default() + }; + model.insert(self.db.as_ref()).await?; + } + Ok(()) + } + + /// Record an open event and return the email_id if valid + pub async fn record_open( + &self, + email_id: Uuid, + ip_address: Option, + user_agent: Option, + ) -> Result<(), EmailError> { + // Verify email exists and has tracking enabled + let email = emails::Entity::find_by_id(email_id) + .one(self.db.as_ref()) + .await? + .ok_or_else(|| EmailError::EmailNotFound(email_id.to_string()))?; + + if !email.track_opens { + debug!("Open tracking not enabled for email {}", email_id); + return Ok(()); + } + + // Record the event + let event = email_events::ActiveModel { + email_id: Set(email_id), + event_type: Set("open".to_string()), + ip_address: Set(ip_address), + user_agent: Set(user_agent), + ..Default::default() + }; + event.insert(self.db.as_ref()).await?; + + // Update email counters + let mut active: emails::ActiveModel = email.into(); + let current_count = active.open_count.clone().unwrap(); + active.open_count = Set(current_count + 1); + if current_count == 0 { + active.first_opened_at = Set(Some(Utc::now())); + } + active.update(self.db.as_ref()).await?; + + debug!("Recorded open event for email {}", email_id); + Ok(()) + } + + /// Record a click event and return the redirect URL + pub async fn record_click( + &self, + email_id: Uuid, + link_index: i32, + ip_address: Option, + user_agent: Option, + ) -> Result { + // Look up the original URL from the links table + let link = email_links::Entity::find() + .filter(email_links::Column::EmailId.eq(email_id)) + .filter(email_links::Column::LinkIndex.eq(link_index)) + .one(self.db.as_ref()) + .await? + .ok_or_else(|| { + EmailError::Validation(format!( + "Link index {} not found for email {}", + link_index, email_id + )) + })?; + + let redirect_url = link.original_url.clone(); + + // Record the event + let event = email_events::ActiveModel { + email_id: Set(email_id), + event_type: Set("click".to_string()), + link_url: Set(Some(redirect_url.clone())), + link_index: Set(Some(link_index)), + ip_address: Set(ip_address), + user_agent: Set(user_agent), + ..Default::default() + }; + event.insert(self.db.as_ref()).await?; + + // Update link click count + let mut active_link: email_links::ActiveModel = link.into(); + let current = active_link.click_count.clone().unwrap(); + active_link.click_count = Set(current + 1); + active_link.update(self.db.as_ref()).await?; + + // Update email click counters + let email = emails::Entity::find_by_id(email_id) + .one(self.db.as_ref()) + .await? + .ok_or_else(|| EmailError::EmailNotFound(email_id.to_string()))?; + + let mut active_email: emails::ActiveModel = email.into(); + let current_count = active_email.click_count.clone().unwrap(); + active_email.click_count = Set(current_count + 1); + if current_count == 0 { + active_email.first_clicked_at = Set(Some(Utc::now())); + } + active_email.update(self.db.as_ref()).await?; + + debug!( + "Recorded click event for email {}, link_index {}", + email_id, link_index + ); + Ok(redirect_url) + } + + /// Get tracking events for an email + pub async fn get_events( + &self, + email_id: Uuid, + event_type: Option<&str>, + ) -> Result, EmailError> { + let mut query = + email_events::Entity::find().filter(email_events::Column::EmailId.eq(email_id)); + + if let Some(et) = event_type { + query = query.filter(email_events::Column::EventType.eq(et)); + } + + let events = query.all(self.db.as_ref()).await?; + Ok(events) + } + + /// Get tracked links for an email + pub async fn get_links(&self, email_id: Uuid) -> Result, EmailError> { + let links = email_links::Entity::find() + .filter(email_links::Column::EmailId.eq(email_id)) + .all(self.db.as_ref()) + .await?; + Ok(links) + } +} + +/// Information about where an href= attribute starts +struct HrefMatch { + offset: usize, + prefix_len: usize, + quote: char, +} + +/// Find the next href="..." or href='...' in the string +fn find_href_start(s: &str) -> Option { + let lower = s.to_lowercase(); + let patterns = ["href=\"", "href='", "href =\"", "href ='"]; + + let mut best: Option<(usize, usize, char)> = None; + + for pattern in &patterns { + if let Some(pos) = lower.find(pattern) { + let quote = if pattern.ends_with('"') { '"' } else { '\'' }; + match best { + Some((best_pos, _, _)) if pos < best_pos => { + best = Some((pos, pattern.len(), quote)); + } + None => { + best = Some((pos, pattern.len(), quote)); + } + _ => {} + } + } + } + + best.map(|(offset, prefix_len, quote)| HrefMatch { + offset, + prefix_len, + quote, + }) +} + +/// Should this link be rewritten for click tracking? +fn should_track_link(url: &str) -> bool { + let trimmed = url.trim(); + if trimmed.is_empty() { + return false; + } + // Only track http and https links + trimmed.starts_with("http://") || trimmed.starts_with("https://") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_service() -> TrackingService { + // Create a mock DB connection for unit tests + let db = sea_orm::MockDatabase::new(sea_orm::DatabaseBackend::Postgres).into_connection(); + TrackingService::new(Arc::new(db), "https://app.example.com".to_string()) + } + + #[test] + fn test_inject_tracking_pixel_with_body_tag() { + let service = create_service(); + let email_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); + let html = "

Hello

"; + + let result = service.inject_tracking_pixel(email_id, html); + + assert!(result.contains("/api/emails/550e8400-e29b-41d4-a716-446655440000/track/open")); + assert!(result.contains(r#"width="1" height="1""#)); + // Pixel should be before + let pixel_pos = result.find("track/open").unwrap(); + let body_pos = result.rfind("").unwrap(); + assert!(pixel_pos < body_pos); + } + + #[test] + fn test_inject_tracking_pixel_without_body_tag() { + let service = create_service(); + let email_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); + let html = "

Hello

World

"; + + let result = service.inject_tracking_pixel(email_id, html); + + assert!(result.contains("track/open")); + assert!(result.ends_with("/>")); + } + + #[test] + fn test_rewrite_links_http() { + let service = create_service(); + let email_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); + let html = r#"
Click here"#; + + let (rewritten, links) = service.rewrite_links(email_id, html); + + assert_eq!(links.len(), 1); + assert_eq!(links[0].index, 0); + assert_eq!(links[0].original_url, "https://example.com/pricing"); + assert!( + rewritten.contains("/api/emails/550e8400-e29b-41d4-a716-446655440000/track/click/0") + ); + assert!(!rewritten.contains("https://example.com/pricing")); + } + + #[test] + fn test_rewrite_links_skips_mailto() { + let service = create_service(); + let email_id = Uuid::new_v4(); + let html = r#"Email us"#; + + let (rewritten, links) = service.rewrite_links(email_id, html); + + assert!(links.is_empty()); + assert!(rewritten.contains("mailto:support@example.com")); + } + + #[test] + fn test_rewrite_links_skips_anchors() { + let service = create_service(); + let email_id = Uuid::new_v4(); + let html = "Jump"; + + let (rewritten, links) = service.rewrite_links(email_id, html); + + assert!(links.is_empty()); + assert!(rewritten.contains("#section")); + } + + #[test] + fn test_rewrite_multiple_links() { + let service = create_service(); + let email_id = Uuid::new_v4(); + let html = r#"A B"#; + + let (rewritten, links) = service.rewrite_links(email_id, html); + + assert_eq!(links.len(), 2); + assert_eq!(links[0].index, 0); + assert_eq!(links[1].index, 1); + assert!(rewritten.contains("track/click/0")); + assert!(rewritten.contains("track/click/1")); + } + + #[test] + fn test_transform_html_both_tracking() { + let service = create_service(); + let email_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); + let html = r#"Link"#; + + let result = service.transform_html(email_id, html, true, true); + + assert!(result.html.contains("track/open")); + assert!(result.html.contains("track/click/0")); + assert_eq!(result.links.len(), 1); + } + + #[test] + fn test_transform_html_no_tracking() { + let service = create_service(); + let email_id = Uuid::new_v4(); + let html = r#"Link"#; + + let result = service.transform_html(email_id, html, false, false); + + assert!(!result.html.contains("track/open")); + assert!(!result.html.contains("track/click")); + assert!(result.links.is_empty()); + } + + #[test] + fn test_should_track_link() { + assert!(should_track_link("https://example.com")); + assert!(should_track_link("http://example.com")); + assert!(!should_track_link("mailto:test@example.com")); + assert!(!should_track_link("tel:+1234567890")); + assert!(!should_track_link("#section")); + assert!(!should_track_link("javascript:void(0)")); + assert!(!should_track_link("")); + } + + #[test] + fn test_rewrite_links_with_single_quotes() { + let service = create_service(); + let email_id = Uuid::new_v4(); + let html = "Link"; + + let (rewritten, links) = service.rewrite_links(email_id, html); + + assert_eq!(links.len(), 1); + assert!(rewritten.contains("track/click/0")); + } + + #[test] + fn test_rewrite_preserves_non_link_content() { + let service = create_service(); + let email_id = Uuid::new_v4(); + let html = r#"

Hello World

Link"#; + + let (rewritten, links) = service.rewrite_links(email_id, html); + + assert_eq!(links.len(), 1); + // img src should NOT be rewritten + assert!(rewritten.contains(r#"src="https://example.com/img.png""#)); + } +} diff --git a/crates/temps-email/src/services/tracking_service_integration_tests.rs b/crates/temps-email/src/services/tracking_service_integration_tests.rs new file mode 100644 index 00000000..41a0f690 --- /dev/null +++ b/crates/temps-email/src/services/tracking_service_integration_tests.rs @@ -0,0 +1,414 @@ +//! Integration tests for the tracking service +//! These tests require Docker (PostgreSQL via testcontainers) + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use sea_orm::{ActiveModelTrait, ActiveValue::Set, EntityTrait}; + use temps_database::test_utils::TestDatabase; + use temps_entities::{email_links, emails}; + use uuid::Uuid; + + use crate::services::TrackingService; + + async fn setup_test_env() -> (TestDatabase, Arc) { + let db = TestDatabase::with_migrations().await.unwrap(); + let tracking_service = Arc::new(TrackingService::new( + db.db.clone(), + "https://app.example.com".to_string(), + )); + (db, tracking_service) + } + + /// Create a test email directly in the database + async fn create_test_email( + db: &Arc, + track_opens: bool, + track_clicks: bool, + ) -> Uuid { + let email_id = Uuid::new_v4(); + let email = emails::ActiveModel { + id: Set(email_id), + from_address: Set("sender@test.com".to_string()), + to_addresses: Set(serde_json::json!(["recipient@test.com"])), + subject: Set("Test email".to_string()), + html_body: Set(Some( + r#"Link 1Link 2"#.to_string(), + )), + status: Set("sent".to_string()), + track_opens: Set(track_opens), + track_clicks: Set(track_clicks), + open_count: Set(0), + click_count: Set(0), + ..Default::default() + }; + email.insert(db.as_ref()).await.unwrap(); + email_id + } + + /// Store test links for an email + async fn create_test_links(db: &Arc, email_id: Uuid) { + for (idx, url) in ["https://example.com/page1", "https://example.com/page2"] + .iter() + .enumerate() + { + let link = email_links::ActiveModel { + email_id: Set(email_id), + link_index: Set(idx as i32), + original_url: Set(url.to_string()), + click_count: Set(0), + ..Default::default() + }; + link.insert(db.as_ref()).await.unwrap(); + } + } + + // ============================================ + // HTML Transformation Tests + // ============================================ + + #[test] + fn test_transform_html_injects_pixel_and_rewrites_links() { + let db = sea_orm::MockDatabase::new(sea_orm::DatabaseBackend::Postgres).into_connection(); + let service = TrackingService::new(Arc::new(db), "https://app.example.com".to_string()); + + let email_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); + let html = r#"PricingDocs"#; + + let result = service.transform_html(email_id, html, true, true); + + // Should have tracking pixel + assert!( + result + .html + .contains("/api/emails/550e8400-e29b-41d4-a716-446655440000/track/open"), + "Missing tracking pixel" + ); + + // Should have rewritten links + assert!( + result.html.contains("/track/click/0"), + "First link not rewritten" + ); + assert!( + result.html.contains("/track/click/1"), + "Second link not rewritten" + ); + + // Should NOT contain original URLs in href (they're replaced) + assert!( + !result + .html + .contains(r#"href="https://example.com/pricing""#), + "Original URL should be replaced" + ); + + // Should have 2 extracted links + assert_eq!(result.links.len(), 2); + assert_eq!(result.links[0].original_url, "https://example.com/pricing"); + assert_eq!(result.links[1].original_url, "https://example.com/docs"); + } + + #[test] + fn test_transform_html_only_opens() { + let db = sea_orm::MockDatabase::new(sea_orm::DatabaseBackend::Postgres).into_connection(); + let service = TrackingService::new(Arc::new(db), "https://app.example.com".to_string()); + + let email_id = Uuid::new_v4(); + let html = r#"Link"#; + + let result = service.transform_html(email_id, html, true, false); + + assert!(result.html.contains("track/open"), "Should have pixel"); + assert!( + !result.html.contains("track/click"), + "Should NOT have click tracking" + ); + assert!(result.links.is_empty(), "Should have no extracted links"); + } + + #[test] + fn test_transform_html_only_clicks() { + let db = sea_orm::MockDatabase::new(sea_orm::DatabaseBackend::Postgres).into_connection(); + let service = TrackingService::new(Arc::new(db), "https://app.example.com".to_string()); + + let email_id = Uuid::new_v4(); + let html = r#"Link"#; + + let result = service.transform_html(email_id, html, false, true); + + assert!(!result.html.contains("track/open"), "Should NOT have pixel"); + assert!( + result.html.contains("track/click"), + "Should have click tracking" + ); + assert_eq!(result.links.len(), 1); + } + + #[test] + fn test_transform_preserves_mailto_and_anchor_links() { + let db = sea_orm::MockDatabase::new(sea_orm::DatabaseBackend::Postgres).into_connection(); + let service = TrackingService::new(Arc::new(db), "https://app.example.com".to_string()); + + let email_id = Uuid::new_v4(); + let html = "Email Top Link"; + + let result = service.transform_html(email_id, html, false, true); + + assert!( + result.html.contains("mailto:test@example.com"), + "mailto should be preserved" + ); + assert!(result.html.contains("#top"), "Anchor should be preserved"); + assert_eq!(result.links.len(), 1, "Only HTTP link should be tracked"); + assert_eq!(result.links[0].original_url, "https://example.com"); + } + + // ============================================ + // Integration Tests (Require Docker) + // ============================================ + + #[tokio::test] + async fn test_record_open_increments_counter() { + let (db, tracking) = setup_test_env().await; + + // Create email with open tracking + let email_id = create_test_email(&db.db, true, false).await; + + // Record first open + tracking + .record_open( + email_id, + Some("1.2.3.4".to_string()), + Some("TestAgent".to_string()), + ) + .await + .unwrap(); + + // Verify email counter was updated + let email = emails::Entity::find_by_id(email_id) + .one(db.db.as_ref()) + .await + .unwrap() + .unwrap(); + assert_eq!(email.open_count, 1); + assert!(email.first_opened_at.is_some()); + + // Record second open + tracking + .record_open( + email_id, + Some("5.6.7.8".to_string()), + Some("TestAgent2".to_string()), + ) + .await + .unwrap(); + + let email = emails::Entity::find_by_id(email_id) + .one(db.db.as_ref()) + .await + .unwrap() + .unwrap(); + assert_eq!(email.open_count, 2); + + // Verify events recorded + let events = tracking.get_events(email_id, Some("open")).await.unwrap(); + assert_eq!(events.len(), 2); + assert_eq!(events[0].ip_address, Some("1.2.3.4".to_string())); + assert_eq!(events[1].ip_address, Some("5.6.7.8".to_string())); + } + + #[tokio::test] + async fn test_record_open_skips_when_tracking_disabled() { + let (db, tracking) = setup_test_env().await; + + // Create email WITHOUT open tracking + let email_id = create_test_email(&db.db, false, false).await; + + // Record open - should not fail but should not increment + tracking + .record_open(email_id, Some("1.2.3.4".to_string()), None) + .await + .unwrap(); + + let email = emails::Entity::find_by_id(email_id) + .one(db.db.as_ref()) + .await + .unwrap() + .unwrap(); + assert_eq!( + email.open_count, 0, + "Should not increment when tracking disabled" + ); + + let events = tracking.get_events(email_id, Some("open")).await.unwrap(); + assert!( + events.is_empty(), + "Should not record event when tracking disabled" + ); + } + + #[tokio::test] + async fn test_record_click_returns_redirect_url() { + let (db, tracking) = setup_test_env().await; + + let email_id = create_test_email(&db.db, false, true).await; + create_test_links(&db.db, email_id).await; + + // Click link index 0 + let redirect_url = tracking + .record_click( + email_id, + 0, + Some("1.2.3.4".to_string()), + Some("Agent".to_string()), + ) + .await + .unwrap(); + + assert_eq!(redirect_url, "https://example.com/page1"); + + // Click link index 1 + let redirect_url = tracking + .record_click(email_id, 1, Some("1.2.3.4".to_string()), None) + .await + .unwrap(); + + assert_eq!(redirect_url, "https://example.com/page2"); + + // Verify counters + let email = emails::Entity::find_by_id(email_id) + .one(db.db.as_ref()) + .await + .unwrap() + .unwrap(); + assert_eq!(email.click_count, 2); + assert!(email.first_clicked_at.is_some()); + + // Verify link click counts + let links = tracking.get_links(email_id).await.unwrap(); + assert_eq!(links.len(), 2); + assert_eq!(links[0].click_count, 1); + assert_eq!(links[1].click_count, 1); + + // Verify events + let events = tracking.get_events(email_id, Some("click")).await.unwrap(); + assert_eq!(events.len(), 2); + assert_eq!(events[0].link_index, Some(0)); + assert_eq!(events[1].link_index, Some(1)); + } + + #[tokio::test] + async fn test_record_click_invalid_link_index() { + let (db, tracking) = setup_test_env().await; + + let email_id = create_test_email(&db.db, false, true).await; + // No links stored + + let result = tracking.record_click(email_id, 999, None, None).await; + + assert!(result.is_err(), "Should fail for invalid link index"); + } + + #[tokio::test] + async fn test_record_open_nonexistent_email() { + let (_db, tracking) = setup_test_env().await; + + let result = tracking.record_open(Uuid::new_v4(), None, None).await; + + assert!(result.is_err(), "Should fail for nonexistent email"); + } + + #[tokio::test] + async fn test_store_and_retrieve_links() { + let (db, tracking) = setup_test_env().await; + + let email_id = create_test_email(&db.db, false, true).await; + + let links = vec![ + crate::services::ExtractedLink { + index: 0, + original_url: "https://example.com/a".to_string(), + }, + crate::services::ExtractedLink { + index: 1, + original_url: "https://example.com/b".to_string(), + }, + ]; + + tracking.store_links(email_id, &links).await.unwrap(); + + let stored = tracking.get_links(email_id).await.unwrap(); + assert_eq!(stored.len(), 2); + assert_eq!(stored[0].original_url, "https://example.com/a"); + assert_eq!(stored[1].original_url, "https://example.com/b"); + assert_eq!(stored[0].click_count, 0); + } + + #[tokio::test] + async fn test_get_events_filtered_by_type() { + let (db, tracking) = setup_test_env().await; + + let email_id = create_test_email(&db.db, true, true).await; + create_test_links(&db.db, email_id).await; + + // Record mixed events + tracking + .record_open(email_id, Some("1.1.1.1".to_string()), None) + .await + .unwrap(); + tracking + .record_click(email_id, 0, Some("2.2.2.2".to_string()), None) + .await + .unwrap(); + tracking + .record_open(email_id, Some("3.3.3.3".to_string()), None) + .await + .unwrap(); + + // Get all events + let all_events = tracking.get_events(email_id, None).await.unwrap(); + assert_eq!(all_events.len(), 3); + + // Filter opens only + let opens = tracking.get_events(email_id, Some("open")).await.unwrap(); + assert_eq!(opens.len(), 2); + + // Filter clicks only + let clicks = tracking.get_events(email_id, Some("click")).await.unwrap(); + assert_eq!(clicks.len(), 1); + } + + #[tokio::test] + async fn test_multiple_clicks_on_same_link() { + let (db, tracking) = setup_test_env().await; + + let email_id = create_test_email(&db.db, false, true).await; + create_test_links(&db.db, email_id).await; + + // Click same link 3 times + for _ in 0..3 { + tracking + .record_click(email_id, 0, Some("1.2.3.4".to_string()), None) + .await + .unwrap(); + } + + // Verify link click count + let links = tracking.get_links(email_id).await.unwrap(); + let link_0 = links.iter().find(|l| l.link_index == 0).unwrap(); + assert_eq!(link_0.click_count, 3); + + // Verify email total click count + let email = emails::Entity::find_by_id(email_id) + .one(db.db.as_ref()) + .await + .unwrap() + .unwrap(); + assert_eq!(email.click_count, 3); + + // first_clicked_at should be set from first click only + assert!(email.first_clicked_at.is_some()); + } +} diff --git a/crates/temps-entities/src/email_events.rs b/crates/temps-entities/src/email_events.rs new file mode 100644 index 00000000..e0d1b45b --- /dev/null +++ b/crates/temps-entities/src/email_events.rs @@ -0,0 +1,37 @@ +//! Email events entity for tracking opens, clicks, and other email events + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; +use temps_core::DBDateTime; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "email_events")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i64, + pub email_id: Uuid, + pub event_type: String, + pub link_url: Option, + pub link_index: Option, + pub ip_address: Option, + pub user_agent: Option, + pub created_at: DBDateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::emails::Entity", + from = "Column::EmailId", + to = "super::emails::Column::Id" + )] + Email, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Email.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/temps-entities/src/email_links.rs b/crates/temps-entities/src/email_links.rs new file mode 100644 index 00000000..6702b0be --- /dev/null +++ b/crates/temps-entities/src/email_links.rs @@ -0,0 +1,33 @@ +//! Email links entity for mapping tracked link indices to original URLs + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "email_links")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i64, + pub email_id: Uuid, + pub link_index: i32, + pub original_url: String, + pub click_count: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::emails::Entity", + from = "Column::EmailId", + to = "super::emails::Column::Id" + )] + Email, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Email.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/temps-entities/src/emails.rs b/crates/temps-entities/src/emails.rs index ba11b501..a480802e 100644 --- a/crates/temps-entities/src/emails.rs +++ b/crates/temps-entities/src/emails.rs @@ -32,6 +32,12 @@ pub struct Model { pub error_message: Option, pub sent_at: Option, pub created_at: DBDateTime, + pub track_opens: bool, + pub track_clicks: bool, + pub open_count: i32, + pub click_count: i32, + pub first_opened_at: Option, + pub first_clicked_at: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/crates/temps-entities/src/lib.rs b/crates/temps-entities/src/lib.rs index 07fd1b70..492398cb 100644 --- a/crates/temps-entities/src/lib.rs +++ b/crates/temps-entities/src/lib.rs @@ -22,6 +22,8 @@ pub mod dns_managed_domains; pub mod dns_providers; pub mod domains; pub mod email_domains; +pub mod email_events; +pub mod email_links; pub mod email_providers; pub mod emails; pub mod env_var_environments; diff --git a/crates/temps-migrations/src/migration/m20260320_000001_add_email_tracking.rs b/crates/temps-migrations/src/migration/m20260320_000001_add_email_tracking.rs new file mode 100644 index 00000000..4ad5133e --- /dev/null +++ b/crates/temps-migrations/src/migration/m20260320_000001_add_email_tracking.rs @@ -0,0 +1,206 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // Add tracking columns to emails table + manager + .alter_table( + Table::alter() + .table(Alias::new("emails")) + .add_column( + ColumnDef::new(Alias::new("track_opens")) + .boolean() + .not_null() + .default(false), + ) + .add_column( + ColumnDef::new(Alias::new("track_clicks")) + .boolean() + .not_null() + .default(false), + ) + .add_column( + ColumnDef::new(Alias::new("open_count")) + .integer() + .not_null() + .default(0), + ) + .add_column( + ColumnDef::new(Alias::new("click_count")) + .integer() + .not_null() + .default(0), + ) + .add_column( + ColumnDef::new(Alias::new("first_opened_at")) + .timestamp_with_time_zone() + .null(), + ) + .add_column( + ColumnDef::new(Alias::new("first_clicked_at")) + .timestamp_with_time_zone() + .null(), + ) + .to_owned(), + ) + .await?; + + // Create email_events table for detailed tracking + manager + .create_table( + Table::create() + .table(Alias::new("email_events")) + .if_not_exists() + .col( + ColumnDef::new(Alias::new("id")) + .big_integer() + .not_null() + .auto_increment() + .primary_key(), + ) + .col(ColumnDef::new(Alias::new("email_id")).uuid().not_null()) + .col( + ColumnDef::new(Alias::new("event_type")) + .string_len(32) + .not_null(), + ) + .col(ColumnDef::new(Alias::new("link_url")).text().null()) + .col(ColumnDef::new(Alias::new("link_index")).integer().null()) + .col( + ColumnDef::new(Alias::new("ip_address")) + .string_len(45) + .null(), + ) + .col(ColumnDef::new(Alias::new("user_agent")).text().null()) + .col( + ColumnDef::new(Alias::new("created_at")) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .to_owned(), + ) + .await?; + + // Create email_links table to map link_index -> original URL + manager + .create_table( + Table::create() + .table(Alias::new("email_links")) + .if_not_exists() + .col( + ColumnDef::new(Alias::new("id")) + .big_integer() + .not_null() + .auto_increment() + .primary_key(), + ) + .col(ColumnDef::new(Alias::new("email_id")).uuid().not_null()) + .col( + ColumnDef::new(Alias::new("link_index")) + .integer() + .not_null(), + ) + .col(ColumnDef::new(Alias::new("original_url")).text().not_null()) + .col( + ColumnDef::new(Alias::new("click_count")) + .integer() + .not_null() + .default(0), + ) + .to_owned(), + ) + .await?; + + // Index on email_events(email_id) for fast lookups + manager + .create_index( + Index::create() + .name("idx_email_events_email_id") + .table(Alias::new("email_events")) + .col(Alias::new("email_id")) + .to_owned(), + ) + .await?; + + // Index on email_events(event_type) for filtering + manager + .create_index( + Index::create() + .name("idx_email_events_event_type") + .table(Alias::new("email_events")) + .col(Alias::new("event_type")) + .to_owned(), + ) + .await?; + + // Index on email_links(email_id, link_index) for click tracking lookups + manager + .create_index( + Index::create() + .name("idx_email_links_email_id_link_index") + .table(Alias::new("email_links")) + .col(Alias::new("email_id")) + .col(Alias::new("link_index")) + .unique() + .to_owned(), + ) + .await?; + + // Foreign key on email_events.email_id -> emails.id + manager + .create_foreign_key( + ForeignKey::create() + .name("fk_email_events_email_id") + .from(Alias::new("email_events"), Alias::new("email_id")) + .to(Alias::new("emails"), Alias::new("id")) + .on_delete(ForeignKeyAction::Cascade) + .to_owned(), + ) + .await?; + + // Foreign key on email_links.email_id -> emails.id + manager + .create_foreign_key( + ForeignKey::create() + .name("fk_email_links_email_id") + .from(Alias::new("email_links"), Alias::new("email_id")) + .to(Alias::new("emails"), Alias::new("id")) + .on_delete(ForeignKeyAction::Cascade) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Alias::new("email_events")).to_owned()) + .await?; + + manager + .drop_table(Table::drop().table(Alias::new("email_links")).to_owned()) + .await?; + + manager + .alter_table( + Table::alter() + .table(Alias::new("emails")) + .drop_column(Alias::new("track_opens")) + .drop_column(Alias::new("track_clicks")) + .drop_column(Alias::new("open_count")) + .drop_column(Alias::new("click_count")) + .drop_column(Alias::new("first_opened_at")) + .drop_column(Alias::new("first_clicked_at")) + .to_owned(), + ) + .await?; + + Ok(()) + } +} diff --git a/crates/temps-migrations/src/migration/mod.rs b/crates/temps-migrations/src/migration/mod.rs index f6311ea3..e69a9828 100644 --- a/crates/temps-migrations/src/migration/mod.rs +++ b/crates/temps-migrations/src/migration/mod.rs @@ -47,6 +47,7 @@ mod m20260313_000002_add_service_error_message; mod m20260314_000001_update_environment_route_trigger; mod m20260315_000001_add_last_activity_at_to_environments; mod m20260315_000002_create_error_alert_rules; +mod m20260320_000001_add_email_tracking; pub struct Migrator; @@ -101,6 +102,7 @@ impl MigratorTrait for Migrator { Box::new(m20260314_000001_update_environment_route_trigger::Migration), Box::new(m20260315_000001_add_last_activity_at_to_environments::Migration), Box::new(m20260315_000002_create_error_alert_rules::Migration), + Box::new(m20260320_000001_add_email_tracking::Migration), ] } } diff --git a/web/src/api/client/types.gen.ts b/web/src/api/client/types.gen.ts index 240d9c16..dcc43932 100644 --- a/web/src/api/client/types.gen.ts +++ b/web/src/api/client/types.gen.ts @@ -2836,9 +2836,21 @@ export type EmailProviderTypeRoute = 'ses' | 'scaleway'; export type EmailResponse = { bcc_addresses?: Array | null; cc_addresses?: Array | null; + /** + * Number of times links in the email were clicked + */ + click_count: number; created_at: string; domain_id?: number | null; error_message?: string | null; + /** + * When a link was first clicked + */ + first_clicked_at?: string | null; + /** + * When the email was first opened + */ + first_opened_at?: string | null; from_address: string; from_name?: string | null; headers?: { @@ -2846,6 +2858,10 @@ export type EmailResponse = { } | null; html_body?: string | null; id: string; + /** + * Number of times the email was opened + */ + open_count: number; project_id?: number | null; provider_message_id?: string | null; reply_to?: string | null; @@ -2855,6 +2871,14 @@ export type EmailResponse = { tags?: Array | null; text_body?: string | null; to_addresses: Array; + /** + * Whether click tracking is enabled + */ + track_clicks: boolean; + /** + * Whether open tracking is enabled + */ + track_opens: boolean; }; export type EmailStatsResponse = { @@ -8130,6 +8154,14 @@ export type SendEmailRequestBody = { * Recipient email addresses */ to: Array; + /** + * Enable click tracking (link rewriting). Defaults to false. + */ + track_clicks?: boolean | null; + /** + * Enable open tracking (tracking pixel injection). Defaults to false. + */ + track_opens?: boolean | null; }; export type SendEmailResponseBody = { diff --git a/web/src/components/email/EmailsSentList.tsx b/web/src/components/email/EmailsSentList.tsx index 577052c3..1a2d6ea5 100644 --- a/web/src/components/email/EmailsSentList.tsx +++ b/web/src/components/email/EmailsSentList.tsx @@ -30,8 +30,10 @@ import { ChevronLeft, ChevronRight, Clock, + Eye, Mail, MailX, + MousePointerClick, Search, } from 'lucide-react' import { useState } from 'react' @@ -58,6 +60,12 @@ interface Email { error_message: string | null sent_at: string | null created_at: string + track_opens: boolean + track_clicks: boolean + open_count: number + click_count: number + first_opened_at: string | null + first_clicked_at: string | null } interface PaginatedEmails { @@ -356,6 +364,8 @@ export function EmailsSentList() { Subject To Status + Opens + Clicks Date @@ -378,6 +388,26 @@ export function EmailsSentList() { + + {email.track_opens ? ( + + + {email.open_count} + + ) : ( + -- + )} + + + {email.track_clicks ? ( + + + {email.click_count} + + ) : ( + -- + )} + {formatDistanceToNow( new Date(email.sent_at || email.created_at), diff --git a/web/src/pages/EmailDetail.tsx b/web/src/pages/EmailDetail.tsx index 7d8ffebf..c2c20fbc 100644 --- a/web/src/pages/EmailDetail.tsx +++ b/web/src/pages/EmailDetail.tsx @@ -22,6 +22,7 @@ import { Eye, FileText, Mail, + MousePointerClick, Tag, } from 'lucide-react' import { useEffect, useRef, useState } from 'react' @@ -328,6 +329,54 @@ function EmailDetailContent({ email }: { email: EmailResponse }) { )} + {/* Tracking Stats */} + {(email.track_opens || email.track_clicks) && ( + + + + + Tracking + + + +
+ {email.track_opens && ( + <> +
+

+ + Opens +

+

{email.open_count}

+ {email.first_opened_at && ( +

+ First: {format(new Date(email.first_opened_at), 'PPp')} +

+ )} +
+ + )} + {email.track_clicks && ( + <> +
+

+ + Clicks +

+

{email.click_count}

+ {email.first_clicked_at && ( +

+ First: {format(new Date(email.first_clicked_at), 'PPp')} +

+ )} +
+ + )} +
+
+
+ )} + {/* Email Content */} {(hasHtml || hasText) && (