Skip to main content

netget/client/milter/
actions.rs

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
121/// Check an action's fields.
122pub 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}