use serde::{Deserialize, Serialize};
pub const MESSAGING_PROTOCOL_VERSION: u32 = 1;
#[derive(Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum MessagingPluginRequest {
Fetch(FetchParams),
CreateDraft(CreateDraftParams),
DraftStatus(DraftStatusParams),
Health(HealthParams),
Capabilities(CapabilitiesParams),
}
#[derive(Debug, Serialize, Deserialize)]
pub struct MessagingPluginResponse {
pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub messages: Option<Vec<FetchedMessage>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub state: Option<DraftState>,
#[serde(skip_serializing_if = "Option::is_none")]
pub address: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub capabilities: Option<Vec<String>>,
}
impl MessagingPluginResponse {
pub fn ok() -> Self {
Self {
ok: true,
error: None,
messages: None,
draft_id: None,
state: None,
address: None,
provider: None,
capabilities: None,
}
}
pub fn error(msg: impl Into<String>) -> Self {
Self {
ok: false,
error: Some(msg.into()),
messages: None,
draft_id: None,
state: None,
address: None,
provider: None,
capabilities: None,
}
}
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Default)]
pub struct FetchParams {
pub since: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub account: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
pub struct FetchedMessage {
pub id: String,
pub from: String,
pub to: String,
pub subject: String,
#[serde(default)]
pub body_text: String,
#[serde(default)]
pub body_html: String,
#[serde(default)]
pub thread_id: String,
pub received_at: String,
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
pub struct CreateDraftParams {
pub draft: DraftEnvelope,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
pub struct DraftEnvelope {
pub to: String,
pub subject: String,
pub body_html: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub in_reply_to: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thread_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body_text: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
pub struct DraftStatusParams {
pub draft_id: String,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
#[serde(rename_all = "snake_case")]
pub enum DraftState {
Drafted,
Sent,
Discarded,
Unknown,
}
impl std::fmt::Display for DraftState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DraftState::Drafted => write!(f, "drafted"),
DraftState::Sent => write!(f, "sent"),
DraftState::Discarded => write!(f, "discarded"),
DraftState::Unknown => write!(f, "unknown"),
}
}
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Default)]
pub struct HealthParams {}
#[derive(Debug, Serialize, Deserialize, PartialEq, Default)]
pub struct CapabilitiesParams {}
#[derive(Debug, thiserror::Error)]
pub enum MessagingPluginError {
#[error("messaging plugin not found: {name}. Install with: ta adapter setup messaging/{name}")]
PluginNotFound { name: String },
#[error("messaging plugin '{name}' op '{op}' failed: {reason}")]
OpFailed {
name: String,
op: String,
reason: String,
},
#[error("messaging plugin '{name}' produced invalid response for op '{op}': {reason}")]
InvalidResponse {
name: String,
op: String,
reason: String,
},
#[error(
"failed to spawn messaging plugin '{command}': {reason}. Ensure the plugin is on PATH."
)]
SpawnFailed { command: String, reason: String },
#[error("messaging plugin '{name}' timed out after {timeout_secs}s for op '{op}'. Increase timeout in plugin.toml.")]
Timeout {
name: String,
op: String,
timeout_secs: u64,
},
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fetch_request_roundtrip() {
let req = MessagingPluginRequest::Fetch(FetchParams {
since: "2026-04-01T00:00:00Z".to_string(),
account: Some("me@example.com".to_string()),
limit: Some(50),
});
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains("\"op\":\"fetch\""));
let parsed: MessagingPluginRequest = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, req);
}
#[test]
fn create_draft_request_roundtrip() {
let req = MessagingPluginRequest::CreateDraft(CreateDraftParams {
draft: DraftEnvelope {
to: "bob@example.com".to_string(),
subject: "Re: Hello".to_string(),
body_html: "<p>Hi Bob!</p>".to_string(),
in_reply_to: Some("<msg123@example.com>".to_string()),
thread_id: Some("thread-abc".to_string()),
body_text: None,
},
});
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains("\"op\":\"create_draft\""));
let parsed: MessagingPluginRequest = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, req);
}
#[test]
fn no_send_op_variant() {
let req = MessagingPluginRequest::Health(HealthParams {});
let json = serde_json::to_string(&req).unwrap();
assert!(
!json.contains("\"send\""),
"Send op must not exist in the protocol"
);
}
#[test]
fn draft_status_request_roundtrip() {
let req = MessagingPluginRequest::DraftStatus(DraftStatusParams {
draft_id: "gmail-draft-abc123".to_string(),
});
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains("\"op\":\"draft_status\""));
let parsed: MessagingPluginRequest = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, req);
}
#[test]
fn health_request_roundtrip() {
let req = MessagingPluginRequest::Health(HealthParams {});
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains("\"op\":\"health\""));
}
#[test]
fn response_ok_roundtrip() {
let resp = MessagingPluginResponse::ok();
let json = serde_json::to_string(&resp).unwrap();
let parsed: MessagingPluginResponse = serde_json::from_str(&json).unwrap();
assert!(parsed.ok);
assert!(parsed.error.is_none());
}
#[test]
fn response_error_roundtrip() {
let resp = MessagingPluginResponse::error("credentials not found");
let json = serde_json::to_string(&resp).unwrap();
let parsed: MessagingPluginResponse = serde_json::from_str(&json).unwrap();
assert!(!parsed.ok);
assert_eq!(parsed.error.as_deref(), Some("credentials not found"));
}
#[test]
fn response_with_draft_id() {
let mut resp = MessagingPluginResponse::ok();
resp.draft_id = Some("gmail-draft-xyz".to_string());
let json = serde_json::to_string(&resp).unwrap();
let parsed: MessagingPluginResponse = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.draft_id.as_deref(), Some("gmail-draft-xyz"));
}
#[test]
fn response_with_messages() {
let mut resp = MessagingPluginResponse::ok();
resp.messages = Some(vec![FetchedMessage {
id: "msg-1".to_string(),
from: "alice@example.com".to_string(),
to: "me@example.com".to_string(),
subject: "Hello".to_string(),
body_text: "Hi there!".to_string(),
body_html: "<p>Hi there!</p>".to_string(),
thread_id: "thread-1".to_string(),
received_at: "2026-04-01T10:00:00Z".to_string(),
}]);
let json = serde_json::to_string(&resp).unwrap();
let parsed: MessagingPluginResponse = serde_json::from_str(&json).unwrap();
let msgs = parsed.messages.unwrap();
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].subject, "Hello");
}
#[test]
fn draft_state_display() {
assert_eq!(DraftState::Drafted.to_string(), "drafted");
assert_eq!(DraftState::Sent.to_string(), "sent");
assert_eq!(DraftState::Discarded.to_string(), "discarded");
assert_eq!(DraftState::Unknown.to_string(), "unknown");
}
#[test]
fn draft_state_roundtrip() {
for state in [
DraftState::Drafted,
DraftState::Sent,
DraftState::Discarded,
DraftState::Unknown,
] {
let json = serde_json::to_string(&state).unwrap();
let parsed: DraftState = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, state);
}
}
#[test]
fn messaging_protocol_version_is_one() {
assert_eq!(MESSAGING_PROTOCOL_VERSION, 1);
}
}