1use crate::llm::actions::{
2 client_trait::{Client, ClientActionResult},
3 protocol_trait::Protocol,
4 ActionDefinition, ParameterDefinition, StartupExamples,
5};
6use crate::protocol::{ConnectContext, EventType};
7use crate::server::milter::{
8 actions::{action, parameter},
9 wire,
10};
11use crate::state::app_state::AppState;
12use anyhow::{bail, ensure, Context, Result};
13use serde_json::{json, Value};
14use std::sync::LazyLock;
15
16#[derive(Default)]
17pub struct MilterClientProtocol;
18impl MilterClientProtocol {
19 pub fn new() -> Self {
20 Self
21 }
22}
23
24pub fn actions() -> Vec<ActionDefinition> {
25 vec![
26 action("milter_connect", "Tell the filter an SMTP client connected.",
27 vec![parameter("hostname", "string", "The SMTP client's hostname", true), parameter("address", "string", "The SMTP client's IPv4 or IPv6 address", true), parameter("port", "number", "The SMTP client's port", false)],
28 json!({"type":"milter_connect","hostname":"client.example","address":"192.0.2.10","port":40000}), "-> milter connect {hostname}"),
29 action("milter_helo", "Pass the client's HELO/EHLO name.",
30 vec![parameter("name", "string", "The HELO/EHLO argument the client gave", true)],
31 json!({"type":"milter_helo","name":"client.example"}), "-> milter helo {name}"),
32 action("milter_mail", "Pass the envelope sender (MAIL FROM).",
33 vec![parameter("sender", "string", "Envelope sender, e.g. <alice@example.com>", true), parameter("esmtp_args", "array", "ESMTP parameters, e.g. [\"SIZE=1000\"]", false)],
34 json!({"type":"milter_mail","sender":"<alice@example.com>"}), "-> milter mail {sender}"),
35 action("milter_rcpt", "Pass one envelope recipient (RCPT TO).",
36 vec![parameter("recipient", "string", "Envelope recipient, e.g. <bob@example.net>", true), parameter("esmtp_args", "array", "ESMTP parameters after the address", false)],
37 json!({"type":"milter_rcpt","recipient":"<bob@example.net>"}), "-> milter rcpt {recipient}"),
38 action("milter_message", "Pass the message: DATA, its headers, end of headers, its body and end of message; the filter's verdict and modifications come back.",
39 vec![parameter("headers", "array", "Each {name, value}, in order", true), parameter("body", "string", "The body as text, lines ending in CRLF", true)],
40 json!({"type":"milter_message","headers":[{"name":"Subject","value":"hello"}],"body":"Hi Bob\r\n"}), "-> milter message"),
41 action("milter_abort", "Abandon the current message; the filter forgets it.", vec![], json!({"type":"milter_abort"}), "-> milter abort"),
42 action("disconnect", "Say QUIT and close the connection to the filter.", vec![], json!({"type":"disconnect"}), "-> milter quit"),
43 ]
44}
45
46pub static CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
47 EventType::new("milter_negotiated", "Connected to the filter and negotiated options; pass it a connection.", json!({"type":"milter_connect","hostname":"client.example","address":"192.0.2.10"}))
48 .with_parameters(vec![
49 parameter("version", "number", "The milter protocol version agreed", true),
50 parameter("actions", "array", "What the filter may do: add_header, change_body, add_rcpt, del_rcpt, change_header, quarantine", true),
51 parameter("protocol", "array", "Stages the filter asked to be left out (no_connect, no_helo, no_mail, no_rcpt, no_data, no_headers, no_eoh, no_body) or not answered (no_reply_*), and skip if it may cut the body short", true),
52 ])
53 .with_actions(actions())
54});
55
56pub static REPLY_EVENT: LazyLock<EventType> = LazyLock::new(|| {
57 EventType::new("milter_reply", "The filter answered a stage.", json!({"type":"milter_rcpt","recipient":"<bob@example.net>"}))
58 .with_parameters(vec![
59 parameter("stage", "string", "connect, helo, mail, rcpt or message", true),
60 parameter("decision", "string", "continue, accept, reject, tempfail, discard or replycode", true),
61 parameter("implicit", "boolean", "True when the filter gave no answer because it asked for this stage to be left out or not answered; the decision is then continue", true),
62 parameter("text", "string", "The SMTP reply for replycode, e.g. 550 5.7.1 rejected", false),
63 parameter("modifications", "array", "At end of message: each {kind, name?, value?, index?, recipient?, body?, reason?}", true),
64 ])
65 .with_actions(actions())
66});
67
68impl Protocol for MilterClientProtocol {
69 fn protocol_name(&self) -> &'static str {
70 "Milter"
71 }
72 fn stack_name(&self) -> &'static str {
73 "ETH>IP>TCP>Milter"
74 }
75 fn keywords(&self) -> Vec<&'static str> {
76 vec!["milter", "mail filter", "libmilter"]
77 }
78 fn description(&self) -> &'static str {
79 "Milter client (the MTA side): passes a mail transaction to a filter and reports its verdicts and modifications"
80 }
81 fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
82 actions()
83 }
84 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
85 vec![]
86 }
87 fn get_event_types(&self) -> Vec<EventType> {
88 vec![CONNECTED_EVENT.clone(), REPLY_EVENT.clone()]
89 }
90 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
91 vec![]
92 }
93 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
94 use crate::protocol::metadata::*;
95 ProtocolMetadataV2::builder()
96 .state(DevelopmentState::Experimental)
97 .privilege_requirement(PrivilegeRequirement::None)
98 .implementation("The server's milter codec, MTA side: option negotiation offering every action and every protocol step it can honour (stages left out, stages not answered, SMFIR_SKIP during the body), then connect, helo, mail, rcpt and the message (data, headers, end of headers, body in 64 KiB chunks, end of message), reading each verdict and the modifications before it")
99 .llm_control("The mail transaction to pass to the filter, and what to do with each verdict")
100 .e2e_testing("tests/client/milter: NetGet's own filter; a pymilter (libmilter) filter and emersion/go-milter's server as independent filters")
101 .notes("Macros are not sent. A stage the filter asked to be left out or not answered is reported as an implicit continue. A stage the filter answers with anything but continue ends the message; the handler decides what follows. A handler chain stops after 8 follow-ups.")
102 .max_inbound_bytes(wire::MAX_PACKET)
103 .build()
104 }
105 fn example_prompt(&self) -> &'static str {
106 "Pass a test message from alice@example.com to bob@example.net through the milter at 127.0.0.1:8891"
107 }
108 fn get_startup_examples(&self) -> StartupExamples {
109 let llm = json!({"type":"open_client","protocol":"milter","remote_addr":"127.0.0.1:8891","instruction":"Run one message from alice to bob through the filter"});
110 let mut static_example = llm.clone();
111 static_example["event_handlers"] = json!([{"event_pattern":"milter_negotiated","handler":{"type":"static","actions":[{"type":"milter_connect","hostname":"client.example","address":"192.0.2.10"}]}}]);
112 let mut scripted = llm.clone();
113 scripted["event_handlers"] = json!([{"event_pattern":"milter_reply","handler":{"type":"script","language":"python","code":"import json,sys\ne=json.load(sys.stdin)['event']\na=[{'type':'milter_helo','name':'client.example'}] if e['stage']=='connect' and e['decision']=='continue' else []\nprint(json.dumps({'actions':a}))"}}]);
114 StartupExamples::new(llm, scripted, static_example)
115 }
116 fn group_name(&self) -> &'static str {
117 "Application"
118 }
119}
120
121pub fn check(v: &Value) -> Result<()> {
123 let field = |k: &str| -> Result<()> {
124 wire::check_field(v[k].as_str().with_context(|| format!("{k} required"))?, k)
125 };
126 match v["type"].as_str().unwrap_or_default() {
127 "milter_connect" => {
128 field("hostname")?;
129 let a = v["address"].as_str().context("address required")?;
130 a.parse::<std::net::IpAddr>()
131 .context("address must be an IP address")?;
132 ensure!(
133 v["port"].as_u64().unwrap_or(0) <= 65535,
134 "port out of range"
135 );
136 }
137 "milter_helo" => field("name")?,
138 "milter_mail" => field("sender")?,
139 "milter_rcpt" => field("recipient")?,
140 "milter_message" => {
141 for h in v["headers"]
142 .as_array()
143 .context("headers must be an array")?
144 {
145 wire::check_field(
146 h["name"].as_str().context("header name required")?,
147 "header name",
148 )?;
149 wire::check_field(
150 h["value"].as_str().context("header value required")?,
151 "header value",
152 )?;
153 }
154 ensure!(
155 v["body"].as_str().context("body required")?.len() <= wire::MAX_BODY,
156 "body larger than 1 MiB"
157 );
158 }
159 "milter_abort" => {}
160 t => bail!("Unknown milter client action {t}"),
161 }
162 Ok(())
163}
164
165impl Client for MilterClientProtocol {
166 fn connect(
167 &self,
168 ctx: ConnectContext,
169 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
170 {
171 Box::pin(super::connect(ctx))
172 }
173 fn execute_action(&self, v: Value) -> Result<ClientActionResult> {
174 let name = v["type"].as_str().unwrap_or_default().to_string();
175 if name == "disconnect" {
176 return Ok(ClientActionResult::Disconnect);
177 }
178 check(&v)?;
179 Ok(ClientActionResult::Custom { name, data: v })
180 }
181}