Skip to main content

wasm_smtp/client/
send.rs

1//! Message-sending methods for [`super::SmtpClient`].
2//!
3//! `send_mail`, `send_mail_bytes`, `send_mail_stream`,
4//! `send_mail_smtputf8`, and `send_message` (mail-builder integration)
5//! live here.
6
7#[cfg(feature = "mail-builder")]
8use crate::error::IoError;
9use crate::error::{InvalidInputError, SmtpError, SmtpOp};
10use crate::outcome::SendOutcome;
11use crate::protocol::{
12    self, DotStufferState, dot_stuff_and_terminate, format_command,
13    format_mail_from, format_rcpt_to,
14};
15#[cfg(feature = "smtputf8")]
16use crate::protocol::{
17    ehlo_advertises_smtputf8, format_mail_from_smtputf8, validate_address_utf8,
18};
19use crate::session::SessionState;
20use crate::tracing_helpers::smtp_debug;
21use crate::transport::Transport;
22use super::SmtpClient;
23
24impl<T: Transport> SmtpClient<T> {
25
26    /// Send a single message.
27    ///
28    /// `from` is the envelope sender (RFC 5321 reverse-path), used in the
29    /// `MAIL FROM:<...>` command. `to` is a non-empty slice of envelope
30    /// recipients (forward-paths). `body` is the fully-formed message,
31    /// including all RFC 5322 headers, separated from the body proper by a
32    /// blank line, and CRLF-normalized. Any line in `body` whose first
33    /// character is `.` is automatically dot-stuffed before transmission.
34    ///
35    /// On success the client is left in a state where another `send_mail`
36    /// may be issued, or `quit` may be called to close the session.
37    ///
38    /// # Body size
39    ///
40    /// `wasm-smtp` does not impose an upper bound on `body.len()`;
41    /// the body is dot-stuffed into a single `Vec<u8>` and written in
42    /// one [`crate::Transport::write_all`] call.
43    /// In practice the caller (or a layer above this crate) should
44    /// enforce a sane application-specific limit, both to avoid the
45    /// allocation cost on a malicious body and to stay within the
46    /// `SIZE` limit (RFC 1870) the server may have advertised in its
47    /// `EHLO` response. A typical safe default for transactional mail
48    /// is 10 MiB; submission relays such as Gmail enforce 25-50 MiB.
49    pub async fn send_mail(
50        &mut self,
51        from: &str,
52        to: &[&str],
53        body: &str,
54    ) -> Result<SendOutcome, SmtpError> {
55        protocol::validate_address(from)?;
56        if to.is_empty() {
57            return Err(InvalidInputError::new("at least one recipient is required").into());
58        }
59        for &addr in to {
60            protocol::validate_address(addr)?;
61        }
62        self.assert_state_in(&[SessionState::Authentication, SessionState::MailFrom])?;
63
64        // Policy checks — run before any SMTP command is sent.
65        self.policy.check_sender(from).map_err(crate::error::SmtpError::Policy)?;
66        self.policy.check_recipients(to).map_err(crate::error::SmtpError::Policy)?;
67        self.policy
68            .check_message_size(body.len())
69            .map_err(crate::error::SmtpError::Policy)?;
70
71        smtp_debug!(
72            from = %from,
73            recipient_count = to.len(),
74            body_bytes = body.len(),
75            "send_mail: starting transaction"
76        );
77
78        // Issue MAIL FROM, RCPT TO, and DATA — with pipelining if the
79        // server advertised it (RFC 2920). Pipelining sends all three
80        // command types in a single write, then reads all responses,
81        // reducing RTTs from 3+N (one per command) to 2 (one flush + one
82        // DATA-body exchange) regardless of recipient count.
83        self.transition(SessionState::MailFrom)?;
84
85        #[cfg(feature = "pipelining")]
86        let pipelining = protocol::ehlo_advertises_pipelining(&self.capabilities);
87        #[cfg(not(feature = "pipelining"))]
88        let pipelining = false;
89
90        if pipelining {
91            // ── Pipelined path ────────────────────────────────────────
92            // Collect MAIL FROM + all RCPT TO + DATA into one buffer,
93            // write once, flush, then read all responses in order.
94            let mut pipeline: Vec<u8> = Vec::with_capacity(
95                64 + to.iter().map(|a| 12 + a.len()).sum::<usize>(),
96            );
97            pipeline.extend_from_slice(&format_mail_from(from));
98            self.transition(SessionState::RcptTo)?;
99            for &addr in to {
100                pipeline.extend_from_slice(&format_rcpt_to(addr));
101            }
102            self.transition(SessionState::Data)?;
103            pipeline.extend_from_slice(&format_command("DATA"));
104            self.write_all(&pipeline).await?;
105            self.flush().await?;
106
107            // Read MAIL FROM response.
108            let mail_reply = self.expect_class(2, SmtpOp::MailFrom).await?;
109            self.audit.on_event(&crate::audit::SmtpAuditEvent::MailFromAccepted {
110                code: mail_reply.code,
111            });
112            smtp_debug!(from = %from, pipelining = true, "MAIL FROM accepted");
113
114            // Read one RCPT TO response per recipient.
115            for _ in to {
116                let rcpt_reply = self.expect_class(2, SmtpOp::RcptTo).await?;
117                self.audit.on_event(&crate::audit::SmtpAuditEvent::RecipientAccepted {
118                    code: rcpt_reply.code,
119                });
120            }
121
122            // Read DATA 354 response.
123            self.expect_code(354, SmtpOp::Data).await?;
124        } else {
125            // ── Sequential path (original) ────────────────────────────
126            self.write_all(&format_mail_from(from)).await?;
127            let mail_reply = self.expect_class(2, SmtpOp::MailFrom).await?;
128            self.audit.on_event(&crate::audit::SmtpAuditEvent::MailFromAccepted {
129                code: mail_reply.code,
130            });
131            smtp_debug!(from = %from, pipelining = false, "MAIL FROM accepted");
132
133            self.transition(SessionState::RcptTo)?;
134            for &addr in to {
135                self.write_all(&format_rcpt_to(addr)).await?;
136                let rcpt_reply = self.expect_class(2, SmtpOp::RcptTo).await?;
137                self.audit.on_event(&crate::audit::SmtpAuditEvent::RecipientAccepted {
138                    code: rcpt_reply.code,
139                });
140                smtp_debug!(rcpt = %addr, "RCPT TO accepted");
141            }
142
143            self.transition(SessionState::Data)?;
144            self.write_all(&format_command("DATA")).await?;
145            self.expect_code(354, SmtpOp::Data).await?;
146        }
147
148        // Send the body with dot-stuffing and terminator. The
149        // post-terminator reply carries the queue id (if the server
150        // assigns one) — capture it and return it to the caller.
151        let payload = dot_stuff_and_terminate(body.as_bytes());
152        self.write_all(&payload).await?;
153        let final_reply = self.expect_class(2, SmtpOp::Data).await?;
154        let outcome = SendOutcome::new(final_reply.code, final_reply.joined_text());
155        self.audit.on_event(&crate::audit::SmtpAuditEvent::MessageAccepted {
156            code: outcome.code,
157        });
158        smtp_debug!(
159            body_bytes = body.len(),
160            code = outcome.code,
161            queue_id = outcome.queue_id.as_deref().unwrap_or("<none>"),
162            "DATA accepted; transaction complete"
163        );
164
165        // Ready for another transaction.
166        self.transition(SessionState::MailFrom)?;
167        Ok(outcome)
168    }
169
170    /// Send a single message using the SMTPUTF8 extension (RFC 6531),
171    /// allowing UTF-8 characters in envelope addresses.
172    ///
173    /// Identical to [`Self::send_mail`] except:
174    ///
175    /// - Address validation uses [`protocol::validate_address_utf8`]
176    ///   instead of the strict ASCII validator, so codepoints outside
177    ///   the ASCII range are accepted in `from` and `to`.
178    /// - The `MAIL FROM` command is suffixed with the `SMTPUTF8`
179    ///   ESMTP parameter so the server knows to expect UTF-8.
180    /// - The server must have advertised `SMTPUTF8` in its `EHLO`
181    ///   response. If it did not, this method returns
182    ///   [`ProtocolError::ExtensionUnavailable`] without sending any
183    ///   bytes.
184    ///
185    /// The body must still be CRLF-normalized; any UTF-8 in headers
186    /// (e.g. `Subject:` containing non-ASCII characters) is the
187    /// caller's responsibility to format correctly. RFC 6531 §3.2
188    /// permits raw UTF-8 in headers when SMTPUTF8 is in effect, but
189    /// strict deployments may still expect MIME encoded-words; this
190    /// crate makes no claim either way.
191    ///
192    /// Convenience: serialize a `mail-builder` `MessageBuilder` to a
193    /// CRLF-normalized string and submit it.
194    ///
195    /// Equivalent to:
196    ///
197    /// ```ignore
198    /// let body = message.write_to_string()?;
199    /// client.send_mail(from, to, &body).await?;
200    /// ```
201    ///
202    /// `from` is the SMTP envelope sender (`MAIL FROM:`); `to` is the
203    /// envelope recipient list (`RCPT TO:`). These are **separate** from
204    /// the `From:` and `To:` headers that `MessageBuilder` writes into
205    /// the message body — they often coincide in practice, but the
206    /// envelope is what the SMTP server uses for routing, while the
207    /// headers are what the recipient's MUA displays. `Bcc` recipients
208    /// must appear in `to` (the envelope) but **not** in any
209    /// `MessageBuilder::bcc(...)` call (or, if they do, `MessageBuilder`
210    /// strips them from the headers when serializing — verify against
211    /// your `mail-builder` version).
212    ///
213    /// Available only with the `mail-builder` cargo feature enabled.
214    ///
215    /// # Errors
216    ///
217    /// All the categories returned by [`Self::send_mail`], plus:
218    ///
219    /// - [`SmtpError::Io`] with the underlying `mail_builder` error
220    ///   preserved as the source chain if `MessageBuilder::write_to_string`
221    ///   fails (effectively only on out-of-memory in current
222    ///   `mail-builder` versions).
223    ///
224    /// # Example
225    ///
226    /// ```ignore
227    /// use mail_builder::MessageBuilder;
228    /// let message = MessageBuilder::new()
229    ///     .from(("Notify", "notify@example.com"))
230    ///     .to("alice@example.org")
231    ///     .subject("Status update")
232    ///     .text_body("Hello.");
233    ///
234    /// client.send_message(
235    ///     "notify@example.com",
236    ///     &["alice@example.org"],
237    ///     message,
238    /// ).await?;
239    /// ```
240    #[cfg(feature = "mail-builder")]
241    pub async fn send_message(
242        &mut self,
243        from: &str,
244        to: &[&str],
245        message: ::mail_builder::MessageBuilder<'_>,
246    ) -> Result<SendOutcome, SmtpError> {
247        let body = message
248            .write_to_string()
249            .map_err(|e| SmtpError::Io(IoError::with_source("failed to serialize message", e)))?;
250        self.send_mail(from, to, &body).await
251    }
252
253    /// Send a single message supplied as a raw byte slice.
254    ///
255    /// Identical to [`Self::send_mail`] except that `body` is `&[u8]`
256    /// rather than `&str`. Use this when the message has already been
257    /// serialised to bytes by a builder such as `mail-builder` or when
258    /// the body may contain non-UTF-8 octets (e.g. binary attachments
259    /// encoded as base64 within a MIME part that uses a legacy charset).
260    ///
261    /// # Body requirements
262    ///
263    /// `body` must be a fully composed RFC 5322 message — headers, a blank
264    /// line, and content — **with CRLF line endings**. [`Self::send_mail`]
265    /// has the same requirement; the difference is that `send_mail_bytes`
266    /// skips the UTF-8 validity check on the input slice.
267    ///
268    /// Dot-stuffing and the end-of-data terminator (`\r\n.\r\n`) are applied
269    /// automatically, exactly as in `send_mail`.
270    ///
271    /// # Policy and audit
272    ///
273    /// The pre-send policy checks and audit events are identical to those
274    /// fired by `send_mail`. `check_message_size` receives
275    /// `body.len()` (the raw byte length before dot-stuffing).
276    ///
277    /// # Errors
278    ///
279    /// Same categories as [`Self::send_mail`].
280    pub async fn send_mail_bytes(
281        &mut self,
282        from: &str,
283        to: &[&str],
284        body: &[u8],
285    ) -> Result<SendOutcome, SmtpError> {
286        protocol::validate_address(from)?;
287        if to.is_empty() {
288            return Err(InvalidInputError::new("at least one recipient is required").into());
289        }
290        for &addr in to {
291            protocol::validate_address(addr)?;
292        }
293        self.assert_state_in(&[SessionState::Authentication, SessionState::MailFrom])?;
294
295        // Policy checks — run before any SMTP command.
296        self.policy.check_sender(from).map_err(crate::error::SmtpError::Policy)?;
297        self.policy.check_recipients(to).map_err(crate::error::SmtpError::Policy)?;
298        self.policy
299            .check_message_size(body.len())
300            .map_err(crate::error::SmtpError::Policy)?;
301
302        smtp_debug!(
303            from = %from,
304            recipient_count = to.len(),
305            body_bytes = body.len(),
306            "send_mail_bytes: starting transaction"
307        );
308
309        // Issue MAIL FROM.
310        self.transition(SessionState::MailFrom)?;
311        self.write_all(&format_mail_from(from)).await?;
312        let mail_reply = self.expect_class(2, SmtpOp::MailFrom).await?;
313        self.audit.on_event(&crate::audit::SmtpAuditEvent::MailFromAccepted {
314            code: mail_reply.code,
315        });
316
317        // Issue RCPT TO for every recipient.
318        self.transition(SessionState::RcptTo)?;
319        for &addr in to {
320            self.write_all(&format_rcpt_to(addr)).await?;
321            let rcpt_reply = self.expect_class(2, SmtpOp::RcptTo).await?;
322            self.audit.on_event(&crate::audit::SmtpAuditEvent::RecipientAccepted {
323                code: rcpt_reply.code,
324            });
325        }
326
327        // Issue DATA, expect 354.
328        self.transition(SessionState::Data)?;
329        self.write_all(&format_command("DATA")).await?;
330        self.expect_code(354, SmtpOp::Data).await?;
331
332        // Send the body with dot-stuffing and terminator.
333        let payload = dot_stuff_and_terminate(body);
334        self.write_all(&payload).await?;
335        let final_reply = self.expect_class(2, SmtpOp::Data).await?;
336        let outcome = SendOutcome::new(final_reply.code, final_reply.joined_text());
337        self.audit.on_event(&crate::audit::SmtpAuditEvent::MessageAccepted {
338            code: outcome.code,
339        });
340        smtp_debug!(
341            body_bytes = body.len(),
342            code = outcome.code,
343            queue_id = outcome.queue_id.as_deref().unwrap_or("<none>"),
344            "DATA accepted; transaction complete"
345        );
346
347        self.transition(SessionState::MailFrom)?;
348        Ok(outcome)
349    }
350
351    /// Send a message supplied as a [`MessageBody`] stream.
352    ///
353    /// This is the streaming variant of [`Self::send_mail_bytes`]. The body
354    /// is read in chunks of `chunk_size` bytes (default: 8 KB), dot-stuffed,
355    /// and written to the transport incrementally. Peak memory usage is
356    /// O(`chunk_size`) rather than O(body size), making this suitable for
357    /// large messages and memory-constrained runtimes.
358    ///
359    /// # Body requirements
360    ///
361    /// The body must be a fully composed RFC 5322 message (headers + blank
362    /// line + content) with **CRLF line endings**. Dot-stuffing and the
363    /// end-of-data terminator are applied automatically.
364    ///
365    /// # Policy and audit
366    ///
367    /// `check_sender` and `check_recipients` run before any SMTP command.
368    /// `check_message_size` is called with `usize::MAX` because the total
369    /// body size is unknown in advance; callers that need accurate size
370    /// enforcement should use [`Self::send_mail_bytes`] instead.
371    ///
372    /// Audit events are identical to [`Self::send_mail`].
373    ///
374    /// # Errors
375    ///
376    /// Same as [`Self::send_mail`], plus:
377    ///
378    /// - [`SmtpError::Io`] if `body.read_chunk` returns an error. The
379    ///   session is moved to `Closed`.
380    pub async fn send_mail_stream<B>(
381        &mut self,
382        from: &str,
383        to: &[&str],
384        body: &mut B,
385    ) -> Result<SendOutcome, SmtpError>
386    where
387        B: crate::message_body::MessageBody,
388    {
389        protocol::validate_address(from)?;
390        if to.is_empty() {
391            return Err(InvalidInputError::new("at least one recipient is required").into());
392        }
393        for &addr in to {
394            protocol::validate_address(addr)?;
395        }
396        self.assert_state_in(&[SessionState::Authentication, SessionState::MailFrom])?;
397
398        // Policy checks. check_message_size receives usize::MAX because the
399        // total size is unknown; callers needing precise limits should use
400        // send_mail_bytes instead.
401        self.policy.check_sender(from).map_err(crate::error::SmtpError::Policy)?;
402        self.policy.check_recipients(to).map_err(crate::error::SmtpError::Policy)?;
403        self.policy
404            .check_message_size(usize::MAX)
405            .map_err(crate::error::SmtpError::Policy)?;
406
407        smtp_debug!(
408            from = %from,
409            recipient_count = to.len(),
410            "send_mail_stream: starting transaction"
411        );
412
413        // MAIL FROM.
414        self.transition(SessionState::MailFrom)?;
415        self.write_all(&format_mail_from(from)).await?;
416        let mail_reply = self.expect_class(2, SmtpOp::MailFrom).await?;
417        self.audit.on_event(&crate::audit::SmtpAuditEvent::MailFromAccepted {
418            code: mail_reply.code,
419        });
420
421        // RCPT TO.
422        self.transition(SessionState::RcptTo)?;
423        for &addr in to {
424            self.write_all(&format_rcpt_to(addr)).await?;
425            let rcpt_reply = self.expect_class(2, SmtpOp::RcptTo).await?;
426            self.audit.on_event(&crate::audit::SmtpAuditEvent::RecipientAccepted {
427                code: rcpt_reply.code,
428            });
429        }
430
431        // DATA.
432        self.transition(SessionState::Data)?;
433        self.write_all(&format_command("DATA")).await?;
434        self.expect_code(354, SmtpOp::Data).await?;
435
436        // Stream the body through the dot-stuffer in 8 KB chunks.
437        let mut stuffer = DotStufferState::new();
438        let mut buf = [0u8; 8192];
439        loop {
440            let n = match body.read_chunk(&mut buf).await {
441                Ok(0) => break,
442                Ok(n) => n,
443                Err(e) => {
444                    self.mark_closed_on_logical_failure();
445                    return Err(SmtpError::Io(e));
446                }
447            };
448            let stuffed = stuffer.process_chunk(&buf[..n]);
449            self.write_all(&stuffed).await?;
450        }
451        // Terminator: ensures the body ends with \r\n then appends .\r\n
452        let terminator = stuffer.finish();
453        self.write_all(&terminator).await?;
454
455        let final_reply = self.expect_class(2, SmtpOp::Data).await?;
456        let outcome = SendOutcome::new(final_reply.code, final_reply.joined_text());
457        self.audit.on_event(&crate::audit::SmtpAuditEvent::MessageAccepted {
458            code: outcome.code,
459        });
460        smtp_debug!(
461            code = outcome.code,
462            queue_id = outcome.queue_id.as_deref().unwrap_or("<none>"),
463            "send_mail_stream: DATA accepted"
464        );
465
466        self.transition(SessionState::MailFrom)?;
467        Ok(outcome)
468    }
469
470    /// Submit a UTF-8 (RFC 6531) message and recipient set.
471    ///
472    /// Identical to [`Self::send_mail`] except:
473    ///
474    /// - Address validation uses [`protocol::validate_address_utf8`]
475    ///   instead of the strict ASCII validator, so codepoints outside
476    ///   the ASCII range are accepted in `from` and `to`.
477    /// - The `MAIL FROM` command is suffixed with the `SMTPUTF8`
478    ///   ESMTP parameter so the server knows to expect UTF-8.
479    /// - The server must have advertised `SMTPUTF8` in its `EHLO`
480    ///   response. If it did not, this method returns
481    ///   [`ProtocolError::ExtensionUnavailable`] without sending any
482    ///   bytes.
483    ///
484    /// The body must still be CRLF-normalized; any UTF-8 in headers
485    /// (e.g. `Subject:` containing non-ASCII characters) is the
486    /// caller's responsibility to format correctly. RFC 6531 §3.2
487    /// permits raw UTF-8 in headers when SMTPUTF8 is in effect, but
488    /// strict deployments may still expect MIME encoded-words; this
489    /// crate makes no claim either way.
490    ///
491    /// Available only with the `smtputf8` cargo feature enabled.
492    ///
493    /// # Errors
494    ///
495    /// In addition to the error categories returned by `send_mail`:
496    ///
497    /// - [`ProtocolError::ExtensionUnavailable`] with `name: "SMTPUTF8"`
498    ///   if the server's `EHLO` reply did not include the keyword.
499    ///   The session is moved to `Closed` to prevent silent fallback
500    ///   to ASCII-only delivery.
501    #[cfg(feature = "smtputf8")]
502    pub async fn send_mail_smtputf8(
503        &mut self,
504        from: &str,
505        to: &[&str],
506        body: &str,
507    ) -> Result<SendOutcome, SmtpError> {
508        protocol::validate_address_utf8(from)?;
509        if to.is_empty() {
510            return Err(InvalidInputError::new("at least one recipient is required").into());
511        }
512        for &addr in to {
513            protocol::validate_address_utf8(addr)?;
514        }
515        self.assert_state_in(&[SessionState::Authentication, SessionState::MailFrom])?;
516
517        if !protocol::ehlo_advertises_smtputf8(&self.capabilities) {
518            self.mark_closed_on_logical_failure();
519            return Err(ProtocolError::ExtensionUnavailable { name: "SMTPUTF8" }.into());
520        }
521
522        // Policy checks — run before any SMTP command.
523        self.policy.check_sender(from).map_err(crate::error::SmtpError::Policy)?;
524        self.policy.check_recipients(to).map_err(crate::error::SmtpError::Policy)?;
525        self.policy
526            .check_message_size(body.len())
527            .map_err(crate::error::SmtpError::Policy)?;
528
529        // Issue MAIL FROM:<from> SMTPUTF8.
530        self.transition(SessionState::MailFrom)?;
531        self.write_all(&protocol::format_mail_from_smtputf8(from))
532            .await?;
533        let mail_reply = self.expect_class(2, SmtpOp::MailFrom).await?;
534        self.audit.on_event(&crate::audit::SmtpAuditEvent::MailFromAccepted {
535            code: mail_reply.code,
536        });
537
538        // RCPT TO is identical to the ASCII path.
539        self.transition(SessionState::RcptTo)?;
540        for &addr in to {
541            self.write_all(&format_rcpt_to(addr)).await?;
542            let rcpt_reply = self.expect_class(2, SmtpOp::RcptTo).await?;
543            self.audit.on_event(&crate::audit::SmtpAuditEvent::RecipientAccepted {
544                code: rcpt_reply.code,
545            });
546        }
547
548        // DATA + body identical to the ASCII path.
549        self.transition(SessionState::Data)?;
550        self.write_all(&format_command("DATA")).await?;
551        self.expect_code(354, SmtpOp::Data).await?;
552
553        let payload = dot_stuff_and_terminate(body.as_bytes());
554        self.write_all(&payload).await?;
555        let final_reply = self.expect_class(2, SmtpOp::Data).await?;
556        let outcome = SendOutcome::new(final_reply.code, final_reply.joined_text());
557        self.audit.on_event(&crate::audit::SmtpAuditEvent::MessageAccepted {
558            code: outcome.code,
559        });
560
561        self.transition(SessionState::MailFrom)?;
562        Ok(outcome)
563    }
564}