nexo-microapp-sdk 0.1.18

Reusable runtime helpers for Phase 11 stdio microapps consuming the nexo-rs daemon (JSON-RPC dispatch loop, BindingContext parsing, typed replies).
//! Engagement tracking newtypes + event records.
//!
//! Kept narrow so `tracking-store` can persist these without
//! re-deriving the wire shape.

use serde::{Deserialize, Serialize};

/// Stable per-outbound-message identifier. Stamped at outbound
/// compose time; embedded in pixel + click URLs so ingest can
/// thread the event back to the originating message.
///
/// Convention: caller stamps a UUIDv4. Format is opaque to the
/// SDK — `&str` is the single source of truth. Don't guess
/// format from the bytes; use the accessor.
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct MsgId(pub String);

impl MsgId {
    /// Wrap a caller-supplied id. No validation — caller owns
    /// shape.
    pub fn new(id: impl Into<String>) -> Self {
        Self(id.into())
    }

    /// Borrow the inner string.
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl std::fmt::Display for MsgId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.0)
    }
}

/// Per-link identifier inside a single message. Generated by
/// [`super::rewrite_links`] when an anchor `href` is rewritten;
/// stable across the message's lifetime so the ingest counter
/// converges (one row per `(msg_id, link_id)` regardless of
/// how many times the operator forwards the email).
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct LinkId(pub String);

impl LinkId {
    /// Wrap a caller-supplied id. No validation — caller owns
    /// shape (typically `L0`, `L1`, … emitted by
    /// [`super::rewrite_links`]).
    pub fn new(id: impl Into<String>) -> Self {
        Self(id.into())
    }

    /// Borrow the inner string.
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl std::fmt::Display for LinkId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.0)
    }
}

/// Persisted mapping of `link_id` → `original_url` produced by
/// [`super::rewrite_links`]. Caller writes one row per entry to
/// the tracking store so the ingest route can resolve the
/// click URL to a 302 target.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LinkMapping {
    /// Per-message link identifier (`L0`, `L1`, …).
    pub link_id: LinkId,
    /// Original `href` value before rewriting. The click
    /// redirector serves this as the 302 `Location`.
    pub original_url: String,
}

/// One open event — what the ingest route persists when the
/// `<img>` pixel fires. `ip_hash` + `ua_hash` are SHA-256
/// truncated to 16 bytes hex so duplicates collapse without
/// retaining raw IPs (GDPR posture).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OpenEvent {
    /// Tenant scope. The signed token's tenant component MUST
    /// match this value exactly — the ingest route guards
    /// cross-tenant attribution.
    pub tenant_id: String,
    /// Stable per-outbound message identifier carried in the
    /// pixel URL.
    pub msg_id: MsgId,
    /// Wall-clock millisecond timestamp the route received the
    /// request.
    pub opened_at_ms: i64,
    /// SHA-256(IP) truncated to 16 hex chars. `None` means the
    /// ingest layer didn't capture the client IP (proxy
    /// stripped it, test fixture, …).
    pub ip_hash: Option<String>,
    /// SHA-256(User-Agent) truncated to 16 hex chars. `None`
    /// for the same reasons as `ip_hash`.
    pub ua_hash: Option<String>,
}

/// One click event — what the ingest route persists when the
/// click redirector fires. `link_id` resolves to the original
/// URL via the store's `lookup_link` call.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClickEvent {
    /// Tenant scope. Must match the signed token's tenant.
    pub tenant_id: String,
    /// Originating message identifier.
    pub msg_id: MsgId,
    /// Per-message link identifier — resolves to
    /// `original_url` via the tracking store.
    pub link_id: LinkId,
    /// Wall-clock millisecond timestamp the route received the
    /// request.
    pub clicked_at_ms: i64,
    /// Hashed client IP (see [`OpenEvent::ip_hash`]).
    pub ip_hash: Option<String>,
    /// Hashed user agent (see [`OpenEvent::ua_hash`]).
    pub ua_hash: Option<String>,
}

/// 1×1 transparent GIF. The smallest browser-renderable image.
/// Served verbatim by the ingest route — no on-the-fly encoding,
/// no allocation. Generated by `convert -size 1x1 xc:none gif:-`
/// then captured as a byte literal.
pub const PIXEL_GIF_BYTES: &[u8] = &[
    0x47, 0x49, 0x46, 0x38, 0x39, 0x61, // GIF89a
    0x01, 0x00, 0x01, 0x00, // 1×1
    0x80, 0x00, 0x00, // Global colour table flag + size
    0x00, 0x00, 0x00, // Colour 0: black (unused)
    0xFF, 0xFF, 0xFF, // Colour 1: white (unused)
    0x21, 0xF9, 0x04, 0x01, 0x00, 0x00, 0x00, 0x00, // GCE: transparent
    0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, // image header
    0x02, 0x02, 0x44, 0x01, 0x00, // image data: 1 pixel = colour 0
    0x3B, // trailer
];

/// MIME for the pixel response. `image/gif` is universally
/// understood by mail clients + browsers.
pub const PIXEL_GIF_CONTENT_TYPE: &str = "image/gif";

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn pixel_gif_is_well_formed() {
        // GIF87a / GIF89a magic.
        assert!(PIXEL_GIF_BYTES.starts_with(b"GIF89a") || PIXEL_GIF_BYTES.starts_with(b"GIF87a"),);
        // Every GIF ends with the trailer byte.
        assert_eq!(PIXEL_GIF_BYTES.last().copied(), Some(0x3B));
        // Sanity bound — a real 1×1 transparent GIF is < 100 B.
        assert!(PIXEL_GIF_BYTES.len() < 100);
    }

    #[test]
    fn newtypes_round_trip_serde() {
        let m = MsgId::new("msg-1");
        let s = serde_json::to_string(&m).unwrap();
        // Serialised as a bare string (newtype tuple).
        assert_eq!(s, "\"msg-1\"");
        let m2: MsgId = serde_json::from_str(&s).unwrap();
        assert_eq!(m, m2);

        let l = LinkId::new("a1");
        let s = serde_json::to_string(&l).unwrap();
        assert_eq!(s, "\"a1\"");
    }

    #[test]
    fn newtypes_display_unwrapped() {
        assert_eq!(MsgId::new("xyz").to_string(), "xyz");
        assert_eq!(LinkId::new("abc").to_string(), "abc");
    }
}