Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `</body>` 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 `<a href="http(s)://...">` 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
Expand Down
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 18 additions & 2 deletions crates/temps-core/src/plugin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PluginRoutes> {
None
}

/// Provide OpenAPI schema for this plugin's endpoints
///
/// Return None if this plugin doesn't have API documentation.
Expand Down Expand Up @@ -692,13 +700,18 @@ 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 {
if let Some(plugin_routes) = plugin.configure_routes(&plugin_context) {
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
Expand All @@ -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)
}
Expand Down
3 changes: 3 additions & 0 deletions crates/temps-email/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
14 changes: 14 additions & 0 deletions crates/temps-email/src/handlers/emails.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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| {
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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))
Expand Down
22 changes: 21 additions & 1 deletion crates/temps-email/src/handlers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ mod audit;
mod domains;
mod emails;
mod providers;
pub mod tracking;
#[cfg(test)]
mod tracking_tests;
mod types;
mod validation;

Expand All @@ -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<Arc<AppState>> {
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<Arc<AppState>> {
tracking::public_routes()
}

#[derive(OpenApi)]
Expand All @@ -45,6 +54,12 @@ pub fn configure_routes() -> Router<Arc<AppState>> {
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,
),
Expand All @@ -71,6 +86,10 @@ pub fn configure_routes() -> Router<Arc<AppState>> {
types::EmailResponse,
types::EmailStatsResponse,
types::PaginatedEmailsResponse,
// Tracking types
tracking::EmailTrackingResponse,
tracking::TrackedLinkResponse,
tracking::TrackingEventResponse,
// Validation types
validation::ValidateEmailRequest,
validation::ValidateEmailResponse,
Expand All @@ -86,6 +105,7 @@ pub fn configure_routes() -> Router<Arc<AppState>> {
(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")
)
)]
Expand Down
Loading
Loading