1use serde::{Deserialize, Serialize};
34
35pub const MESSAGING_PROTOCOL_VERSION: u32 = 1;
37
38#[derive(Debug, Serialize, Deserialize, PartialEq)]
50#[serde(tag = "op", rename_all = "snake_case")]
51pub enum MessagingPluginRequest {
52 Fetch(FetchParams),
54
55 CreateDraft(CreateDraftParams),
59
60 DraftStatus(DraftStatusParams),
62
63 Health(HealthParams),
65
66 Capabilities(CapabilitiesParams),
68}
69
70#[derive(Debug, Serialize, Deserialize)]
79pub struct MessagingPluginResponse {
80 pub ok: bool,
82
83 #[serde(skip_serializing_if = "Option::is_none")]
85 pub error: Option<String>,
86
87 #[serde(skip_serializing_if = "Option::is_none")]
89 pub messages: Option<Vec<FetchedMessage>>,
90
91 #[serde(skip_serializing_if = "Option::is_none")]
93 pub draft_id: Option<String>,
94
95 #[serde(skip_serializing_if = "Option::is_none")]
97 pub state: Option<DraftState>,
98
99 #[serde(skip_serializing_if = "Option::is_none")]
101 pub address: Option<String>,
102
103 #[serde(skip_serializing_if = "Option::is_none")]
105 pub provider: Option<String>,
106
107 #[serde(skip_serializing_if = "Option::is_none")]
109 pub capabilities: Option<Vec<String>>,
110}
111
112impl MessagingPluginResponse {
113 pub fn ok() -> Self {
116 Self {
117 ok: true,
118 error: None,
119 messages: None,
120 draft_id: None,
121 state: None,
122 address: None,
123 provider: None,
124 capabilities: None,
125 }
126 }
127
128 pub fn error(msg: impl Into<String>) -> Self {
130 Self {
131 ok: false,
132 error: Some(msg.into()),
133 messages: None,
134 draft_id: None,
135 state: None,
136 address: None,
137 provider: None,
138 capabilities: None,
139 }
140 }
141}
142
143#[derive(Debug, Serialize, Deserialize, PartialEq, Default)]
149pub struct FetchParams {
150 pub since: String,
153
154 #[serde(skip_serializing_if = "Option::is_none")]
157 pub account: Option<String>,
158
159 #[serde(skip_serializing_if = "Option::is_none")]
162 pub limit: Option<u32>,
163}
164
165#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
167pub struct FetchedMessage {
168 pub id: String,
170
171 pub from: String,
173
174 pub to: String,
176
177 pub subject: String,
179
180 #[serde(default)]
182 pub body_text: String,
183
184 #[serde(default)]
186 pub body_html: String,
187
188 #[serde(default)]
190 pub thread_id: String,
191
192 pub received_at: String,
194}
195
196#[derive(Debug, Serialize, Deserialize, PartialEq)]
207pub struct CreateDraftParams {
208 pub draft: DraftEnvelope,
210}
211
212#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
214pub struct DraftEnvelope {
215 pub to: String,
217
218 pub subject: String,
220
221 pub body_html: String,
223
224 #[serde(skip_serializing_if = "Option::is_none")]
226 pub in_reply_to: Option<String>,
227
228 #[serde(skip_serializing_if = "Option::is_none")]
230 pub thread_id: Option<String>,
231
232 #[serde(skip_serializing_if = "Option::is_none")]
234 pub body_text: Option<String>,
235}
236
237#[derive(Debug, Serialize, Deserialize, PartialEq)]
243pub struct DraftStatusParams {
244 pub draft_id: String,
246}
247
248#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
253#[serde(rename_all = "snake_case")]
254pub enum DraftState {
255 Drafted,
257 Sent,
259 Discarded,
261 Unknown,
263}
264
265impl std::fmt::Display for DraftState {
266 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
267 match self {
268 DraftState::Drafted => write!(f, "drafted"),
269 DraftState::Sent => write!(f, "sent"),
270 DraftState::Discarded => write!(f, "discarded"),
271 DraftState::Unknown => write!(f, "unknown"),
272 }
273 }
274}
275
276#[derive(Debug, Serialize, Deserialize, PartialEq, Default)]
284pub struct HealthParams {}
285
286#[derive(Debug, Serialize, Deserialize, PartialEq, Default)]
294pub struct CapabilitiesParams {}
295
296#[derive(Debug, thiserror::Error)]
302pub enum MessagingPluginError {
303 #[error("messaging plugin not found: {name}. Install with: ta adapter setup messaging/{name}")]
304 PluginNotFound { name: String },
305
306 #[error("messaging plugin '{name}' op '{op}' failed: {reason}")]
307 OpFailed {
308 name: String,
309 op: String,
310 reason: String,
311 },
312
313 #[error("messaging plugin '{name}' produced invalid response for op '{op}': {reason}")]
314 InvalidResponse {
315 name: String,
316 op: String,
317 reason: String,
318 },
319
320 #[error(
321 "failed to spawn messaging plugin '{command}': {reason}. Ensure the plugin is on PATH."
322 )]
323 SpawnFailed { command: String, reason: String },
324
325 #[error("messaging plugin '{name}' timed out after {timeout_secs}s for op '{op}'. Increase timeout in plugin.toml.")]
326 Timeout {
327 name: String,
328 op: String,
329 timeout_secs: u64,
330 },
331
332 #[error("I/O error: {0}")]
333 Io(#[from] std::io::Error),
334
335 #[error("JSON error: {0}")]
336 Json(#[from] serde_json::Error),
337}
338
339#[cfg(test)]
344mod tests {
345 use super::*;
346
347 #[test]
348 fn fetch_request_roundtrip() {
349 let req = MessagingPluginRequest::Fetch(FetchParams {
350 since: "2026-04-01T00:00:00Z".to_string(),
351 account: Some("me@example.com".to_string()),
352 limit: Some(50),
353 });
354 let json = serde_json::to_string(&req).unwrap();
355 assert!(json.contains("\"op\":\"fetch\""));
356 let parsed: MessagingPluginRequest = serde_json::from_str(&json).unwrap();
357 assert_eq!(parsed, req);
358 }
359
360 #[test]
361 fn create_draft_request_roundtrip() {
362 let req = MessagingPluginRequest::CreateDraft(CreateDraftParams {
363 draft: DraftEnvelope {
364 to: "bob@example.com".to_string(),
365 subject: "Re: Hello".to_string(),
366 body_html: "<p>Hi Bob!</p>".to_string(),
367 in_reply_to: Some("<msg123@example.com>".to_string()),
368 thread_id: Some("thread-abc".to_string()),
369 body_text: None,
370 },
371 });
372 let json = serde_json::to_string(&req).unwrap();
373 assert!(json.contains("\"op\":\"create_draft\""));
374 let parsed: MessagingPluginRequest = serde_json::from_str(&json).unwrap();
375 assert_eq!(parsed, req);
376 }
377
378 #[test]
379 fn no_send_op_variant() {
380 let req = MessagingPluginRequest::Health(HealthParams {});
383 let json = serde_json::to_string(&req).unwrap();
384 assert!(
385 !json.contains("\"send\""),
386 "Send op must not exist in the protocol"
387 );
388 }
389
390 #[test]
391 fn draft_status_request_roundtrip() {
392 let req = MessagingPluginRequest::DraftStatus(DraftStatusParams {
393 draft_id: "gmail-draft-abc123".to_string(),
394 });
395 let json = serde_json::to_string(&req).unwrap();
396 assert!(json.contains("\"op\":\"draft_status\""));
397 let parsed: MessagingPluginRequest = serde_json::from_str(&json).unwrap();
398 assert_eq!(parsed, req);
399 }
400
401 #[test]
402 fn health_request_roundtrip() {
403 let req = MessagingPluginRequest::Health(HealthParams {});
404 let json = serde_json::to_string(&req).unwrap();
405 assert!(json.contains("\"op\":\"health\""));
406 }
407
408 #[test]
409 fn response_ok_roundtrip() {
410 let resp = MessagingPluginResponse::ok();
411 let json = serde_json::to_string(&resp).unwrap();
412 let parsed: MessagingPluginResponse = serde_json::from_str(&json).unwrap();
413 assert!(parsed.ok);
414 assert!(parsed.error.is_none());
415 }
416
417 #[test]
418 fn response_error_roundtrip() {
419 let resp = MessagingPluginResponse::error("credentials not found");
420 let json = serde_json::to_string(&resp).unwrap();
421 let parsed: MessagingPluginResponse = serde_json::from_str(&json).unwrap();
422 assert!(!parsed.ok);
423 assert_eq!(parsed.error.as_deref(), Some("credentials not found"));
424 }
425
426 #[test]
427 fn response_with_draft_id() {
428 let mut resp = MessagingPluginResponse::ok();
429 resp.draft_id = Some("gmail-draft-xyz".to_string());
430 let json = serde_json::to_string(&resp).unwrap();
431 let parsed: MessagingPluginResponse = serde_json::from_str(&json).unwrap();
432 assert_eq!(parsed.draft_id.as_deref(), Some("gmail-draft-xyz"));
433 }
434
435 #[test]
436 fn response_with_messages() {
437 let mut resp = MessagingPluginResponse::ok();
438 resp.messages = Some(vec![FetchedMessage {
439 id: "msg-1".to_string(),
440 from: "alice@example.com".to_string(),
441 to: "me@example.com".to_string(),
442 subject: "Hello".to_string(),
443 body_text: "Hi there!".to_string(),
444 body_html: "<p>Hi there!</p>".to_string(),
445 thread_id: "thread-1".to_string(),
446 received_at: "2026-04-01T10:00:00Z".to_string(),
447 }]);
448 let json = serde_json::to_string(&resp).unwrap();
449 let parsed: MessagingPluginResponse = serde_json::from_str(&json).unwrap();
450 let msgs = parsed.messages.unwrap();
451 assert_eq!(msgs.len(), 1);
452 assert_eq!(msgs[0].subject, "Hello");
453 }
454
455 #[test]
456 fn draft_state_display() {
457 assert_eq!(DraftState::Drafted.to_string(), "drafted");
458 assert_eq!(DraftState::Sent.to_string(), "sent");
459 assert_eq!(DraftState::Discarded.to_string(), "discarded");
460 assert_eq!(DraftState::Unknown.to_string(), "unknown");
461 }
462
463 #[test]
464 fn draft_state_roundtrip() {
465 for state in [
466 DraftState::Drafted,
467 DraftState::Sent,
468 DraftState::Discarded,
469 DraftState::Unknown,
470 ] {
471 let json = serde_json::to_string(&state).unwrap();
472 let parsed: DraftState = serde_json::from_str(&json).unwrap();
473 assert_eq!(parsed, state);
474 }
475 }
476
477 #[test]
478 fn messaging_protocol_version_is_one() {
479 assert_eq!(MESSAGING_PROTOCOL_VERSION, 1);
480 }
481}