Skip to main content

ta_submit/
messaging_plugin_protocol.rs

1//! JSON-over-stdio protocol types for external messaging adapter plugins.
2//!
3//! Messaging adapter plugins communicate with TA using a request/response
4//! protocol over stdin/stdout. TA spawns the plugin process, writes one JSON
5//! request line to stdin, reads one JSON response line from stdout.
6//!
7//! ## Protocol overview
8//!
9//! Every exchange is a single JSON line in each direction:
10//!
11//! ```text
12//! TA → plugin: {"op":"<name>",...params...}
13//! plugin → TA: {"ok":true,...result...}   or   {"ok":false,"error":"..."}
14//! ```
15//!
16//! ## Operations
17//!
18//! | Op              | Description                                              |
19//! |-----------------|----------------------------------------------------------|
20//! | `fetch`         | Fetch messages since a watermark timestamp               |
21//! | `create_draft`  | Write a draft to the provider's native Drafts folder     |
22//! | `draft_status`  | Poll whether a draft was sent, discarded, or still open  |
23//! | `health`        | Connectivity + credential check                          |
24//! | `capabilities`  | Advertise which optional ops this plugin supports        |
25//!
26//! ## Safety invariant — `send` is absent by design
27//!
28//! The `send` operation is intentionally absent from this protocol.
29//! TA never sends messages on behalf of the user. Plugins expose only
30//! `create_draft`; the user sends from their native email client.
31//! This is a deliberate safety boundary enforced at the type level.
32
33use serde::{Deserialize, Serialize};
34
35/// Protocol version implemented by this TA build.
36pub const MESSAGING_PROTOCOL_VERSION: u32 = 1;
37
38// ---------------------------------------------------------------------------
39// Request envelope
40// ---------------------------------------------------------------------------
41
42/// Request sent from TA to a messaging plugin over stdin.
43///
44/// One JSON line per request. The plugin processes it and writes one
45/// `MessagingPluginResponse` line to stdout, then the process exits.
46///
47/// The `op` field selects the operation. Additional fields carry
48/// operation-specific parameters (flat layout, not nested).
49#[derive(Debug, Serialize, Deserialize, PartialEq)]
50#[serde(tag = "op", rename_all = "snake_case")]
51pub enum MessagingPluginRequest {
52    /// Fetch messages received since the given ISO-8601 timestamp.
53    Fetch(FetchParams),
54
55    /// Create a draft in the provider's native Drafts folder.
56    ///
57    /// NOTE: There is intentionally no `Send` variant. TA never sends.
58    CreateDraft(CreateDraftParams),
59
60    /// Poll the current state of a previously created draft.
61    DraftStatus(DraftStatusParams),
62
63    /// Connectivity and credential health check.
64    Health(HealthParams),
65
66    /// Advertise optional capabilities supported by this plugin.
67    Capabilities(CapabilitiesParams),
68}
69
70// ---------------------------------------------------------------------------
71// Response envelope
72// ---------------------------------------------------------------------------
73
74/// Response sent from a messaging plugin to TA over stdout.
75///
76/// One JSON line per response. Always contains `ok`; on success contains
77/// the operation result fields; on failure contains `error`.
78#[derive(Debug, Serialize, Deserialize)]
79pub struct MessagingPluginResponse {
80    /// Whether the operation succeeded.
81    pub ok: bool,
82
83    /// Human-readable error message (only set when ok=false).
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub error: Option<String>,
86
87    /// Fetched messages (only for fetch op).
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub messages: Option<Vec<FetchedMessage>>,
90
91    /// Native draft ID assigned by the provider (only for create_draft op).
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub draft_id: Option<String>,
94
95    /// Current state of a draft (only for draft_status op).
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub state: Option<DraftState>,
98
99    /// Connected email address (only for health op).
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub address: Option<String>,
102
103    /// Provider name reported by the plugin (only for health op).
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub provider: Option<String>,
106
107    /// Capabilities declared by the plugin (only for capabilities op).
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub capabilities: Option<Vec<String>>,
110}
111
112impl MessagingPluginResponse {
113    /// Construct a success response with no result fields (used for ops that
114    /// return only `ok:true`).
115    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    /// Construct an error response.
129    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// ---------------------------------------------------------------------------
144// fetch
145// ---------------------------------------------------------------------------
146
147/// Parameters for the `fetch` operation.
148#[derive(Debug, Serialize, Deserialize, PartialEq, Default)]
149pub struct FetchParams {
150    /// ISO-8601 timestamp. Only messages received at or after this time are
151    /// returned. Use `"1970-01-01T00:00:00Z"` to fetch all.
152    pub since: String,
153
154    /// Email account to fetch from (e.g., "me@example.com").
155    /// If omitted, the plugin uses its configured default account.
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub account: Option<String>,
158
159    /// Maximum number of messages to return per call.
160    /// Plugins may impose a lower internal cap.
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub limit: Option<u32>,
163}
164
165/// A single fetched message.
166#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
167pub struct FetchedMessage {
168    /// Provider-specific message identifier.
169    pub id: String,
170
171    /// Sender address (e.g., "Alice <alice@example.com>").
172    pub from: String,
173
174    /// Recipient address(es).
175    pub to: String,
176
177    /// Message subject line.
178    pub subject: String,
179
180    /// Plain-text body (may be empty if only HTML is available).
181    #[serde(default)]
182    pub body_text: String,
183
184    /// HTML body (may be empty).
185    #[serde(default)]
186    pub body_html: String,
187
188    /// Provider thread/conversation identifier.
189    #[serde(default)]
190    pub thread_id: String,
191
192    /// ISO-8601 timestamp when the message was received.
193    pub received_at: String,
194}
195
196// ---------------------------------------------------------------------------
197// create_draft
198// ---------------------------------------------------------------------------
199
200/// Parameters for the `create_draft` operation.
201///
202/// The plugin writes this draft to the provider's native Drafts folder
203/// (Gmail `drafts.create`, Outlook `POST /messages` with `isDraft:true`,
204/// IMAP `APPEND` to Drafts mailbox). The user sees the draft in their
205/// email client, edits freely, and sends when ready.
206#[derive(Debug, Serialize, Deserialize, PartialEq)]
207pub struct CreateDraftParams {
208    /// Draft envelope to create.
209    pub draft: DraftEnvelope,
210}
211
212/// The content of a draft message to be created.
213#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
214pub struct DraftEnvelope {
215    /// Recipient address(es).
216    pub to: String,
217
218    /// Subject line.
219    pub subject: String,
220
221    /// HTML body. Plain text is derived from this if the provider requires it.
222    pub body_html: String,
223
224    /// Message-ID of the original message being replied to (for threading).
225    #[serde(skip_serializing_if = "Option::is_none")]
226    pub in_reply_to: Option<String>,
227
228    /// Provider thread identifier (for Gmail/Outlook thread association).
229    #[serde(skip_serializing_if = "Option::is_none")]
230    pub thread_id: Option<String>,
231
232    /// Plain-text alternative body. If omitted, plugins derive it from body_html.
233    #[serde(skip_serializing_if = "Option::is_none")]
234    pub body_text: Option<String>,
235}
236
237// ---------------------------------------------------------------------------
238// draft_status
239// ---------------------------------------------------------------------------
240
241/// Parameters for the `draft_status` operation.
242#[derive(Debug, Serialize, Deserialize, PartialEq)]
243pub struct DraftStatusParams {
244    /// Provider-specific draft ID returned by `create_draft`.
245    pub draft_id: String,
246}
247
248/// Current state of a draft as reported by the provider.
249///
250/// `Sent` and `Discarded` are best-effort — providers may not reliably
251/// report these states. `Drafted` is the safe default.
252#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
253#[serde(rename_all = "snake_case")]
254pub enum DraftState {
255    /// Draft exists in the Drafts folder and has not been sent.
256    Drafted,
257    /// The user sent this draft from their email client.
258    Sent,
259    /// The draft was deleted by the user without sending.
260    Discarded,
261    /// State cannot be determined (e.g., provider API limitations).
262    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// ---------------------------------------------------------------------------
277// health
278// ---------------------------------------------------------------------------
279
280/// Parameters for the `health` operation.
281///
282/// No parameters required — plugins use their configured credentials.
283#[derive(Debug, Serialize, Deserialize, PartialEq, Default)]
284pub struct HealthParams {}
285
286// ---------------------------------------------------------------------------
287// capabilities
288// ---------------------------------------------------------------------------
289
290/// Parameters for the `capabilities` operation.
291///
292/// No parameters required.
293#[derive(Debug, Serialize, Deserialize, PartialEq, Default)]
294pub struct CapabilitiesParams {}
295
296// ---------------------------------------------------------------------------
297// Error type
298// ---------------------------------------------------------------------------
299
300/// Errors from messaging plugin operations.
301#[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// ---------------------------------------------------------------------------
340// Tests
341// ---------------------------------------------------------------------------
342
343#[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        // The protocol MUST NOT have a Send variant. Verify by ensuring the
381        // enum exhaustively matches without it.
382        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}