Skip to main content

postcrate_core/
recording.rs

1//! `.postcrate` recording format (/31/32).
2//!
3//! A recording captures a sequence of received emails — their
4//! envelopes plus base64-encoded raw bytes — into a single JSON
5//! document. Two intended uses:
6//!
7//!   - **Test fixtures.** Load a `.postcrate` file in a test setup,
8//!     replay it into an ephemeral mailbox, and run the test against
9//!     a known inbox state.
10//!   - **Reproduction.** Share captured traffic with a teammate or
11//!     attach it to a bug report.
12//!
13//! The format is deliberately plain JSON with no external blobs so
14//! the file is self-contained. Versioned via `version: 1` so future
15//! additions (e.g. inline attachments) can be parsed by old readers.
16
17use base64::engine::general_purpose::STANDARD as B64;
18use base64::Engine;
19use serde::{Deserialize, Serialize};
20
21use crate::error::{Error, Result};
22
23pub const RECORDING_VERSION: u32 = 1;
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26#[cfg_attr(feature = "specta", derive(specta::Type))]
27#[serde(rename_all = "camelCase")]
28pub struct Recording {
29    pub version: u32,
30    pub exported_at: i64,
31    /// Optional human-readable label so a user can tell two files apart.
32    #[serde(default)]
33    pub label: Option<String>,
34    pub messages: Vec<RecordedMessage>,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38#[cfg_attr(feature = "specta", derive(specta::Type))]
39#[serde(rename_all = "camelCase")]
40pub struct RecordedMessage {
41    pub envelope: RecordedEnvelope,
42    /// Base64-encoded RFC 5322 bytes (what the SMTP DATA phase
43    /// produced before parsing). Storing raw keeps replay 1:1.
44    pub raw_b64: String,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
48#[cfg_attr(feature = "specta", derive(specta::Type))]
49#[serde(rename_all = "camelCase")]
50pub struct RecordedEnvelope {
51    pub mail_from: String,
52    pub rcpt_to: Vec<String>,
53    pub received_at: i64,
54    pub ext_smtputf8: bool,
55    pub ext_8bitmime: bool,
56    /// Recorded for diagnostics; not used by replay (subject is in `raw`).
57    pub subject: Option<String>,
58}
59
60impl Recording {
61    pub fn new(label: Option<String>) -> Self {
62        Self {
63            version: RECORDING_VERSION,
64            exported_at: chrono::Utc::now().timestamp_millis(),
65            label,
66            messages: Vec::new(),
67        }
68    }
69
70    /// Validate the recording is something we can replay. Currently
71    /// just checks the version is one we know.
72    pub fn validate(&self) -> Result<()> {
73        if self.version != RECORDING_VERSION {
74            return Err(Error::Invalid(format!(
75                "unsupported recording version {} (expected {})",
76                self.version, RECORDING_VERSION
77            )));
78        }
79        Ok(())
80    }
81}
82
83/// Decode a message's raw bytes from its base64 payload.
84pub fn decode_raw(msg: &RecordedMessage) -> Result<Vec<u8>> {
85    B64.decode(&msg.raw_b64)
86        .map_err(|e| Error::Invalid(format!("base64 decode: {e}")))
87}
88
89/// Encode raw bytes into a `RecordedMessage`'s base64 payload.
90pub fn encode_raw(raw: &[u8]) -> String {
91    B64.encode(raw)
92}