Skip to content

Commit 0ef587e

Browse files
authored
fix(email): email tracking fixes, analytics endpoints, SDK regen, settings validation (#57)
* fix(email): detect Gmail image proxy in user agent parsing Google proxies all email images through googleusercontent.com servers, sending a UA like "Mozilla/5.0 ... Firefox/11.0 (via ggpht.com GoogleImageProxy)". The parseUserAgent function now checks for GoogleImageProxy/ggpht.com before browser detection, displaying "Gmail (Google Proxy)" instead of incorrectly showing "Firefox". * fix(email): show empty state for headers tab when no custom headers * fix(email): navigate back to Sent Emails tab from email detail * feat(email): add global tracking stats and events endpoints Add /emails/events/stats and /emails/events endpoints to the temps-email plugin so the Analytics tab works. Previously these routes were only in the unregistered temps-email-tracking plugin. - GET /emails/events/stats: aggregated open/click/bounce stats - GET /emails/events: paginated list of all tracking events - Fix event type aliases in EmailAnalytics component * fix(email): add email_id to TrackingEventResponse to fix analytics crash * feat(sdk): regenerate Node SDK with email tracking types - Add OpenAPI annotations to global events endpoints - Regenerate Node SDK from latest OpenAPI spec - New types: TrackingEventResponse, TrackedLinkResponse, EmailTrackingResponse, tracked_html_body, track_opens, track_clicks - New SDK functions: getEmailTracking, getEmailEvents, getEmailLinks, trackOpen, trackClick * fix(settings): validate external URL on client and server Server: reject external URLs that don't start with http(s)://, contain # or ? characters, or aren't parseable. Sanitize by trimming whitespace and trailing slashes. Client: add react-hook-form validation with the same rules, show inline error messages, and surface server validation errors in toast. * docs(changelog): add email tracking fixes and SDK regen
1 parent e936825 commit 0ef587e

File tree

12 files changed

+78544
-46539
lines changed

12 files changed

+78544
-46539
lines changed

CHANGELOG.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111
- **Email tracking analytics UI**: event timeline on email detail page showing individual open/click/bounce/delivery events with IP, user-agent, and metadata; new Analytics tab with delivery rate cards and global event log
12-
- **Global email events endpoint**: `GET /emails/events` lists tracking events across all emails with optional `email_id` and `event_type` filters
12+
- **Global email events endpoint**: `GET /emails/events` lists tracking events across all emails with optional `email_id` and `event_type` filters; `GET /emails/events/stats` returns aggregated open/click/bounce rates
13+
- **Node SDK regenerated**: includes `tracked_html_body`, `track_opens`, `track_clicks`, `TrackingEventResponse`, `TrackedLinkResponse`, `EmailTrackingResponse` types and SDK functions
1314
- **Multi-preset detection**: `detect_all_presets_from_files` returns all matching presets per directory (e.g., Dockerfile + Next.js + Docker Compose in the same root), letting users choose their preferred deployment method instead of silently picking the highest-priority match
1415
- **Database pool configuration**: env vars `TEMPS_DB_MAX_CONNECTIONS` (default 100), `TEMPS_DB_MIN_CONNECTIONS` (default 1), `TEMPS_DB_ACQUIRE_TIMEOUT` (default 30s), and `TEMPS_DB_IDLE_TIMEOUT` (default 600s) for tuning the SQLx connection pool on resource-constrained servers
1516
- **Enter-submit in wizards**: `useEnterSubmit` hook added to Domain, DNS Provider, Domain Creation, and Import wizards — pressing Enter advances to the next step or submits on the final step
@@ -28,6 +29,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2829

2930
### Fixed
3031
- Email event timeline returned 404: UI fetched from `/emails/{id}/events` (unregistered plugin route) instead of `/emails/{id}/tracking/events`; also fixed event type mismatches (`open`/`click` vs `opened`/`clicked`) and added client-side pagination for flat array response
32+
- Gmail image proxy misidentified as "Firefox" in email tracking events; now shows "Gmail (Google Proxy)"
33+
- Email detail back button navigated to default Providers tab instead of Sent Emails tab
34+
- Empty headers tab showed blank content instead of empty state message
35+
- Email analytics crashed with `Cannot read properties of undefined (reading 'substring')` due to missing `email_id` in `TrackingEventResponse`
36+
- External URL in platform settings accepted invalid values with `#` or `?` characters; now validated on both client and server
3137
- Email tracking open/click endpoints returned 500 (`RequestMetadata` extension not found) because public routes lacked the middleware; now gracefully falls back to extracting IP/UA from headers
3238
- Email tracking events failed with `column "link_url" does not exist` on databases where the column was missing; added migration to backfill
3339
- Database connections could accumulate without recycling due to missing `idle_timeout` on the connection pool; now defaults to 10 minutes

crates/temps-config/src/handler.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,27 @@ async fn update_settings(
333333
}
334334
}
335335

336+
// Validate and sanitize external_url
337+
if let Some(ref mut ext_url) = settings.external_url {
338+
*ext_url = ext_url.trim().to_string();
339+
*ext_url = ext_url.trim_end_matches('/').to_string();
340+
if !ext_url.starts_with("http://") && !ext_url.starts_with("https://") {
341+
return Err(ErrorBuilder::new(StatusCode::BAD_REQUEST)
342+
.detail("External URL must start with http:// or https://")
343+
.build());
344+
}
345+
if ext_url.contains('#') || ext_url.contains('?') {
346+
return Err(ErrorBuilder::new(StatusCode::BAD_REQUEST)
347+
.detail("External URL must not contain '#' or '?' characters")
348+
.build());
349+
}
350+
if url::Url::parse(ext_url).is_err() {
351+
return Err(ErrorBuilder::new(StatusCode::BAD_REQUEST)
352+
.detail("External URL is not a valid URL")
353+
.build());
354+
}
355+
}
356+
336357
match app_state.config_service.update_settings(settings).await {
337358
Ok(_) => {
338359
let audit = SettingsUpdatedAudit {

crates/temps-email/src/handlers/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ pub fn configure_public_routes() -> Router<Arc<AppState>> {
5757
// Tracking
5858
tracking::track_open,
5959
tracking::track_click,
60+
tracking::get_global_event_stats,
61+
tracking::get_global_events,
6062
tracking::get_email_tracking,
6163
tracking::get_email_events,
6264
tracking::get_email_links,
@@ -90,6 +92,8 @@ pub fn configure_public_routes() -> Router<Arc<AppState>> {
9092
tracking::EmailTrackingResponse,
9193
tracking::TrackedLinkResponse,
9294
tracking::TrackingEventResponse,
95+
tracking::GlobalEventStatsResponse,
96+
tracking::PaginatedEventsResponse,
9397
// Validation types
9498
validation::ValidateEmailRequest,
9599
validation::ValidateEmailResponse,

crates/temps-email/src/handlers/tracking.rs

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ pub fn public_routes() -> Router<Arc<AppState>> {
6363
/// Configure authenticated tracking data routes
6464
pub fn routes() -> Router<Arc<AppState>> {
6565
Router::new()
66+
.route("/emails/events/stats", get(get_global_event_stats))
67+
.route("/emails/events", get(get_global_events))
6668
.route("/emails/{id}/tracking", get(get_email_tracking))
6769
.route("/emails/{id}/tracking/events", get(get_email_events))
6870
.route("/emails/{id}/tracking/links", get(get_email_links))
@@ -168,6 +170,140 @@ pub async fn track_click(
168170
}
169171
}
170172

173+
// ============================================================
174+
// GLOBAL TRACKING ENDPOINTS
175+
// ============================================================
176+
177+
#[derive(Debug, Serialize, ToSchema)]
178+
pub struct GlobalEventStatsResponse {
179+
pub delivered: u64,
180+
pub opened: u64,
181+
pub clicked: u64,
182+
pub bounced: u64,
183+
pub complained: u64,
184+
pub open_rate: Option<f64>,
185+
pub click_rate: Option<f64>,
186+
pub bounce_rate: Option<f64>,
187+
}
188+
189+
#[derive(Debug, Serialize, ToSchema)]
190+
pub struct PaginatedEventsResponse {
191+
pub events: Vec<TrackingEventResponse>,
192+
pub total: u64,
193+
pub page: u64,
194+
pub page_size: u64,
195+
}
196+
197+
#[derive(Debug, Deserialize)]
198+
pub struct GlobalEventsQuery {
199+
pub event_type: Option<String>,
200+
pub page: Option<u64>,
201+
pub page_size: Option<u64>,
202+
}
203+
204+
/// GET /emails/events/stats
205+
#[utoipa::path(
206+
tag = "Email Tracking",
207+
get,
208+
path = "/emails/events/stats",
209+
responses(
210+
(status = 200, description = "Global tracking statistics", body = GlobalEventStatsResponse),
211+
(status = 401, description = "Unauthorized"),
212+
),
213+
security(("bearer_auth" = []))
214+
)]
215+
pub async fn get_global_event_stats(
216+
RequireAuth(auth): RequireAuth,
217+
State(state): State<Arc<AppState>>,
218+
) -> Result<impl IntoResponse, Problem> {
219+
permission_guard!(auth, EmailsRead);
220+
221+
let stats = state
222+
.tracking_service
223+
.get_global_stats()
224+
.await
225+
.map_err(|e| {
226+
error!("Failed to get global tracking stats: {}", e);
227+
internal_server_error()
228+
.detail("Failed to get tracking statistics")
229+
.build()
230+
})?;
231+
232+
Ok(Json(GlobalEventStatsResponse {
233+
delivered: stats.delivered,
234+
opened: stats.opened,
235+
clicked: stats.clicked,
236+
bounced: stats.bounced,
237+
complained: stats.complained,
238+
open_rate: stats.open_rate,
239+
click_rate: stats.click_rate,
240+
bounce_rate: stats.bounce_rate,
241+
}))
242+
}
243+
244+
/// GET /emails/events
245+
#[utoipa::path(
246+
tag = "Email Tracking",
247+
get,
248+
path = "/emails/events",
249+
params(
250+
("event_type" = Option<String>, Query, description = "Filter by event type (open, click)"),
251+
("page" = Option<u64>, Query, description = "Page number (default: 1)"),
252+
("page_size" = Option<u64>, Query, description = "Page size (default: 20, max: 100)"),
253+
),
254+
responses(
255+
(status = 200, description = "Paginated tracking events", body = PaginatedEventsResponse),
256+
(status = 401, description = "Unauthorized"),
257+
),
258+
security(("bearer_auth" = []))
259+
)]
260+
pub async fn get_global_events(
261+
RequireAuth(auth): RequireAuth,
262+
State(state): State<Arc<AppState>>,
263+
Query(query): Query<GlobalEventsQuery>,
264+
) -> Result<impl IntoResponse, Problem> {
265+
permission_guard!(auth, EmailsRead);
266+
267+
let page = query.page.unwrap_or(1);
268+
let page_size = std::cmp::min(query.page_size.unwrap_or(20), 100);
269+
270+
let (events, total) = state
271+
.tracking_service
272+
.get_all_events(query.event_type.as_deref(), page, page_size)
273+
.await
274+
.map_err(|e| {
275+
error!("Failed to get global events: {}", e);
276+
internal_server_error()
277+
.detail("Failed to get tracking events")
278+
.build()
279+
})?;
280+
281+
let response = PaginatedEventsResponse {
282+
events: events
283+
.into_iter()
284+
.map(|e| TrackingEventResponse {
285+
id: e.id,
286+
email_id: e.email_id.to_string(),
287+
event_type: e.event_type,
288+
link_url: e.link_url,
289+
link_index: e.link_index,
290+
ip_address: e.ip_address,
291+
user_agent: e.user_agent,
292+
created_at: e.created_at.to_rfc3339(),
293+
})
294+
.collect(),
295+
total,
296+
page,
297+
page_size,
298+
};
299+
300+
Ok(Json(response))
301+
}
302+
303+
// ============================================================
304+
// PER-EMAIL TRACKING ENDPOINTS
305+
// ============================================================
306+
171307
/// Email tracking summary
172308
#[derive(Debug, Serialize, ToSchema)]
173309
pub struct EmailTrackingResponse {
@@ -195,6 +331,7 @@ pub struct TrackedLinkResponse {
195331
#[derive(Debug, Serialize, ToSchema)]
196332
pub struct TrackingEventResponse {
197333
pub id: i64,
334+
pub email_id: String,
198335
pub event_type: String,
199336
pub link_url: Option<String>,
200337
pub link_index: Option<i32>,
@@ -337,6 +474,7 @@ pub async fn get_email_events(
337474
.into_iter()
338475
.map(|e| TrackingEventResponse {
339476
id: e.id,
477+
email_id: e.email_id.to_string(),
340478
event_type: e.event_type,
341479
link_url: e.link_url,
342480
link_index: e.link_index,

crates/temps-email/src/services/tracking_service.rs

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,19 @@ pub struct ExtractedLink {
3737
pub original_url: String,
3838
}
3939

40+
/// Global tracking statistics
41+
#[derive(Debug, Clone)]
42+
pub struct GlobalTrackingStats {
43+
pub delivered: u64,
44+
pub opened: u64,
45+
pub clicked: u64,
46+
pub bounced: u64,
47+
pub complained: u64,
48+
pub open_rate: Option<f64>,
49+
pub click_rate: Option<f64>,
50+
pub bounce_rate: Option<f64>,
51+
}
52+
4053
/// Event recorded for tracking
4154
#[derive(Debug, Clone)]
4255
pub struct TrackingEvent {
@@ -339,6 +352,93 @@ impl TrackingService {
339352
Ok(events)
340353
}
341354

355+
/// Get global tracking stats (aggregated from emails table)
356+
pub async fn get_global_stats(&self) -> Result<GlobalTrackingStats, EmailError> {
357+
use sea_orm::{DatabaseBackend, FromQueryResult, Statement};
358+
359+
#[derive(FromQueryResult)]
360+
struct StatsRow {
361+
total_sent: i64,
362+
total_opens: i64,
363+
total_clicks: i64,
364+
emails_with_opens: i64,
365+
emails_with_clicks: i64,
366+
}
367+
368+
let sql = r#"
369+
SELECT
370+
COUNT(*) FILTER (WHERE status = 'sent') AS total_sent,
371+
COALESCE(SUM(open_count), 0) AS total_opens,
372+
COALESCE(SUM(click_count), 0) AS total_clicks,
373+
COUNT(*) FILTER (WHERE open_count > 0) AS emails_with_opens,
374+
COUNT(*) FILTER (WHERE click_count > 0) AS emails_with_clicks
375+
FROM emails
376+
WHERE track_opens = true OR track_clicks = true
377+
"#;
378+
379+
let row = StatsRow::find_by_statement(Statement::from_string(
380+
DatabaseBackend::Postgres,
381+
sql.to_string(),
382+
))
383+
.one(self.db.as_ref())
384+
.await?
385+
.unwrap_or(StatsRow {
386+
total_sent: 0,
387+
total_opens: 0,
388+
total_clicks: 0,
389+
emails_with_opens: 0,
390+
emails_with_clicks: 0,
391+
});
392+
393+
let delivered = row.total_sent;
394+
let open_rate = if delivered > 0 {
395+
Some(row.emails_with_opens as f64 / delivered as f64 * 100.0)
396+
} else {
397+
None
398+
};
399+
let click_rate = if delivered > 0 {
400+
Some(row.emails_with_clicks as f64 / delivered as f64 * 100.0)
401+
} else {
402+
None
403+
};
404+
405+
Ok(GlobalTrackingStats {
406+
delivered: delivered as u64,
407+
opened: row.total_opens as u64,
408+
clicked: row.total_clicks as u64,
409+
bounced: 0,
410+
complained: 0,
411+
open_rate,
412+
click_rate,
413+
bounce_rate: Some(0.0),
414+
})
415+
}
416+
417+
/// Get all tracking events across all emails (paginated)
418+
pub async fn get_all_events(
419+
&self,
420+
event_type: Option<&str>,
421+
page: u64,
422+
page_size: u64,
423+
) -> Result<(Vec<email_events::Model>, u64), EmailError> {
424+
use sea_orm::PaginatorTrait;
425+
426+
let mut query = email_events::Entity::find();
427+
428+
if let Some(et) = event_type {
429+
query = query.filter(email_events::Column::EventType.eq(et));
430+
}
431+
432+
let paginator = query
433+
.order_by_desc(email_events::Column::Id)
434+
.paginate(self.db.as_ref(), page_size);
435+
436+
let total = paginator.num_items().await?;
437+
let events = paginator.fetch_page(page.saturating_sub(1)).await?;
438+
439+
Ok((events, total))
440+
}
441+
342442
/// Get tracked links for an email
343443
pub async fn get_links(&self, email_id: Uuid) -> Result<Vec<email_links::Model>, EmailError> {
344444
let links = email_links::Entity::find()

0 commit comments

Comments
 (0)