Repository navigation
Conversation
Delivery.Body takes a buffer.Buffer, which can be reopened and has a known length, so a message source has to buffer the whole body before passing it on. A target that only copies the body somewhere once does not need that. StreamingDelivery lets such a target receive the body as an io.Reader while the source is still receiving it. It is optional, so existing targets and the buffer.Buffer contract are unchanged. CanStreamBody is separate from BodyStream because whether streaming is possible can depend on the recipients added to the delivery.
The body is copied into the DATA command as it is read. If reading it fails midway, the connection is dropped instead of closed with QUIT: net/textproto terminates an open DATA before writing the next command, so QUIT would make the downstream server accept the partial message. testutils.Target gets an option to implement StreamingDelivery, and DoTestDeliveryStream mirrors DoTestDelivery for it.
The pipeline can pass the body through without buffering only when no check, modifier or DMARC evaluation needs to read it first, and the message goes to exactly one target that can stream. Received and Authentication-Results are still added, since they only touch the header.
After reading the header, DATA asks the delivery whether it can stream the body. If so, the body goes straight from the client connection to the target, which for a single target.smtp or target.lmtp means no copy of the message is kept in memory or on disk. If the body fails midway (for example the client disconnects), the delivery is left in place so that the following Reset aborts it. LMTPData keeps buffering, since it relies on per-recipient statuses from BodyNonAtomic. Fixes foxcpp#882.
|
Need to into what other modules can benefit from this optimization.
Kinda unfortunate to see any checks and modifiers disabling optimization but let's leave that for a second step. That needs more design work. |
| s.cleanSession() | ||
| }() | ||
|
|
||
| if err := s.checkRoutingLoops(header); err != nil { |
There was a problem hiding this comment.
checkRoutingLoops does not need buffered body and can run after readHeader, avoiding the need to duplicate its call in streamBody.
Same goes for cleanSession that can be separated into its own defer independent of buf.Remove.
Also accepted log.
checkRoutingLoops only needs the header, so it now runs once right after readHeader for both the buffered and the streamed body. The buffer removal and cleanSession are separate defers, and Commit and the "accepted" log are shared as well. cleanSession is deferred only once the body was passed to the delivery successfully. If Body or BodyStream fails, s.delivery is kept and the Reset that go-smtp calls after DATA aborts it. Before, a failed Body in the buffered path cleaned the session without ever calling Abort.
The queue reads the body once to write it to the queue directory, so it can take it directly from the message source in all cases. storeNewMessage now takes an io.Reader, and Body opens the buffer before calling it. If reading the body fails, storeNewMessage already removes the partially written files.
Other targets implementing StreamingDelivery need to tell an error from the message source apart from their own errors as well.
The body can be streamed if the wrapped delivery can stream it, or if there is no wrapped delivery because StartDelivery failed. Errors from the wrapped delivery are still ignored, but an error from reading the body comes from the message source and is returned. After a wrapped error the rest of the body is read and discarded, as the buffered path would have, so a read error after that point is not lost.
The body can be streamed only if it is sent over one connection, that is, all recipients are in one domain. With more connections, each of them needs to read the body, so it is still buffered. If reading the body fails, the connection is dropped without QUIT, as in target/smtp, so the partial message is not accepted, and it is not returned to the pool. closeConn now skips connections that were already dropped.
|
Pushed five commits on top of 14fbb8e:
Each package has new tests, including a read error mid-body. In Docker (golang:1.26 with libpam-dev) |
Summary
When the SMTP endpoint delivers a message to a single target that can stream, and nothing in the pipeline reads the body, the body is now copied straight from the inbound connection into that target instead of being buffered first. The targets that can stream are
target.smtpandtarget.lmtp,target.queue,target.remotewhen the message needs a single connection (one recipient domain), andtarget.ignore_errorwhen the target it wraps can. Every other configuration buffers exactly as before.This goes through a new optional interface,
module.StreamingDelivery, somodule.Deliveryand thebuffer.Buffercontract stay unchanged.Changes
framework/module: new optionalStreamingDeliveryinterface withCanStreamBody() boolandBodyStream(ctx, header, io.Reader) error.target/smtp: implements it. If the inbound body fails midway, the downstream connection is dropped instead of closed with QUIT (see below).msgpipeline: implements it.CanStreamBodyis true only with no checks (global, source or per-recipient), no modifiers, DMARC off, and exactly one target that can stream.ReceivedandAuthentication-Resultsare still added. The header part ofBodymoved into a helper thatBodyStreamshares.endpoint/smtp:Datareads the header, then streams if the delivery allows it. If the stream fails, the delivery is left in place so the followingResetaborts it.LMTPDatais unchanged.testutils.Targetgets aStreamingoption, plusDoTestDeliveryStream.Partial messages
The part that needs care is a client that fails halfway through DATA, since by then the downstream server has part of the message.
io.ErrUnexpectedEOF(and the BDAT pipe returnsErrDataReset) when the client goes away before the final dot, so a truncated body is never mistaken for a complete one.smtpconn.C.Closeis not safe at that point. It sends QUIT throughnet/textproto, whosePrintfLinefirst closes any openDotWriter, so the downstream server would receive the terminating dot and accept the truncated message.BodyStreamtherefore callsDirectCloseas soon as a read fails, andAbort/Commitbecome no-ops afterwards.TestDownstreamDelivery_BodyStream_ReadErrfails with the message delivered if that drop is removed.Testing
Run in a
golang:1.26Linux container withlibpam-dev, using the commands fromtest.yml:go test ./... -coverprofile=coverage.out -covermode=atomicpasses.cd tests && ./run.shhas 1 failure,TestSMTPSASLExternalTLS, which fails the same way in CI ondev(tests/testdata/tls/server.crtis only produced byinit.shthere and is not committed).TestLMTPClient_Is_Actually_LMTPandTestLMTPClient_Issue308forwardsmtptotarget.lmtpwith no checks, so they now take the streaming path, and pass../build.shand./build.sh --destdir destdir/ installpass.endpoint/dovecot_sasld, a file this PR does not touch.New tests:
TestDownstreamDelivery_BodyStream,TestDownstreamDelivery_BodyStream_ReadErr(no message reaches the downstream server, no leaked connection).TestMsgPipeline_BodyStream,TestMsgPipeline_CanStreamBody(one streaming target allows it, a non-streaming target, two targets, a global check, a per-recipient check, a source modifier or DMARC each disable it).TestSMTPDelivery_StreamBody,TestSMTPDelivery_StreamBody_AbortData(client disconnects after header and part of the body, nothing is committed)...._ReadErr,..._AbortData,TestSMTPDelivery_StreamBodyand the two-target, check, modifier and DMARC cases ofTestMsgPipeline_CanStreamBodywere each checked to fail with the corresponding code removed.Design notes
The issue leaves the design open, so here is what I picked and what I considered.
Deliveryobject, likePartialDelivery.CanStreamBodyis separate fromBodyStreambecause the answer depends on routing done inAddRcpt, and the endpoint has to know before it starts reading the body. Putting it onDeliveryTargetwould not work for the same reason.Bufferinstead of a new method. Abuffer.BufferwhoseOpenworks once would avoid the interface, but it breaks the documented contract (reopenable,Len()known up front), and everyBodyimplementation and body check would need auditing for a secondOpenor aLen()call. Adding a method toDeliveryitself would break every out-of-tree target.target.lmtp) is covered. Inbound LMTP (LMTPData) still buffers, because streaming there would collapse the per-recipient statuses from a downstream LMTP server into one.replace_rcpt. Letting a check or modifier declare that it never reads the body would widen this later.Two questions where your preference would change the code:
BodyStreamthat reports "not possible" before reading anything?Fixes #882