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}