mirror of
https://github.com/mjl-/mox.git
synced 2024-12-26 08:23:48 +03:00
localserve: change queue to deliver to localserve smtp server
instead of skipping on any smtp and delivering messages to accounts. we dial the ip of the smtp listener, which is localhost:1025 by default. the smtp server now uses a mock dns resolver during spf & dkim verification for hosted domains (localhost by default), so they should pass. the advantage is that we get regular full smtp server behaviour for delivering in localserve, including webhooks, and potential first-time sender delays (though this is disabled by default now). incoming deliveries now go through normal address resolution, where before we would always deliver to mox@localhost. we still accept email for unknown recipients to mox@localhost. this will be useful upcoming alias/list functionality. localserve will now generate a dkim key when creating a new config. existing users may wish to reset (remove) their localserve directory, or add a dkim key.
This commit is contained in:
parent
2bb4f78657
commit
1cf7477642
9 changed files with 209 additions and 222 deletions
|
@ -33,6 +33,9 @@ import (
|
||||||
"github.com/mjl-/mox/stub"
|
"github.com/mjl-/mox/stub"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// If set, signatures for top-level domain "localhost" are accepted.
|
||||||
|
var Localserve bool
|
||||||
|
|
||||||
var (
|
var (
|
||||||
MetricSign stub.CounterVec = stub.CounterVecIgnore{}
|
MetricSign stub.CounterVec = stub.CounterVecIgnore{}
|
||||||
MetricVerify stub.HistogramVec = stub.HistogramVecIgnore{}
|
MetricVerify stub.HistogramVec = stub.HistogramVecIgnore{}
|
||||||
|
@ -442,7 +445,7 @@ func checkSignatureParams(ctx context.Context, log mlog.Log, sig *Sig) (hash cry
|
||||||
if subdom.Unicode != "" {
|
if subdom.Unicode != "" {
|
||||||
subdom.Unicode = "x." + subdom.Unicode
|
subdom.Unicode = "x." + subdom.Unicode
|
||||||
}
|
}
|
||||||
if orgDom := publicsuffix.Lookup(ctx, log.Logger, subdom); subdom.ASCII == orgDom.ASCII {
|
if orgDom := publicsuffix.Lookup(ctx, log.Logger, subdom); subdom.ASCII == orgDom.ASCII && !(Localserve && sig.Domain.ASCII == "localhost") {
|
||||||
return 0, false, false, fmt.Errorf("%w: %s", ErrTLD, sig.Domain)
|
return 0, false, false, fmt.Errorf("%w: %s", ErrTLD, sig.Domain)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
12
doc.go
12
doc.go
|
@ -764,9 +764,12 @@ automatically initialized with configuration files, an account with email
|
||||||
address mox@localhost and password moxmoxmox, and a newly generated self-signed
|
address mox@localhost and password moxmoxmox, and a newly generated self-signed
|
||||||
TLS certificate.
|
TLS certificate.
|
||||||
|
|
||||||
All incoming email to any address is accepted (if checks pass) and delivered to
|
Incoming messages are delivered as normal, falling back to accepting and
|
||||||
the account that is submitting the message, unless the recipient localpart ends
|
delivering to the mox account for unknown addresses.
|
||||||
with:
|
Submitted messages are added to the queue, which delivers by ignoring the
|
||||||
|
destination servers, always connecting to itself instead.
|
||||||
|
|
||||||
|
Recipient addresses with the following localpart suffixes are handled specially:
|
||||||
|
|
||||||
- "temperror": fail with a temporary error code
|
- "temperror": fail with a temporary error code
|
||||||
- "permerror": fail with a permanent error code
|
- "permerror": fail with a permanent error code
|
||||||
|
@ -774,8 +777,7 @@ with:
|
||||||
- "timeout": no response (for an hour)
|
- "timeout": no response (for an hour)
|
||||||
|
|
||||||
If the localpart begins with "mailfrom" or "rcptto", the error is returned
|
If the localpart begins with "mailfrom" or "rcptto", the error is returned
|
||||||
during those commands instead of during "data". If the localpart begins with
|
during those commands instead of during "data".
|
||||||
"queue", the submission is accepted but delivery from the queue will fail.
|
|
||||||
|
|
||||||
usage: mox localserve
|
usage: mox localserve
|
||||||
-dir string
|
-dir string
|
||||||
|
|
|
@ -26,6 +26,8 @@ import (
|
||||||
"github.com/mjl-/sconf"
|
"github.com/mjl-/sconf"
|
||||||
|
|
||||||
"github.com/mjl-/mox/config"
|
"github.com/mjl-/mox/config"
|
||||||
|
"github.com/mjl-/mox/dkim"
|
||||||
|
"github.com/mjl-/mox/dns"
|
||||||
"github.com/mjl-/mox/junk"
|
"github.com/mjl-/mox/junk"
|
||||||
"github.com/mjl-/mox/mlog"
|
"github.com/mjl-/mox/mlog"
|
||||||
"github.com/mjl-/mox/mox-"
|
"github.com/mjl-/mox/mox-"
|
||||||
|
@ -49,9 +51,12 @@ automatically initialized with configuration files, an account with email
|
||||||
address mox@localhost and password moxmoxmox, and a newly generated self-signed
|
address mox@localhost and password moxmoxmox, and a newly generated self-signed
|
||||||
TLS certificate.
|
TLS certificate.
|
||||||
|
|
||||||
All incoming email to any address is accepted (if checks pass) and delivered to
|
Incoming messages are delivered as normal, falling back to accepting and
|
||||||
the account that is submitting the message, unless the recipient localpart ends
|
delivering to the mox account for unknown addresses.
|
||||||
with:
|
Submitted messages are added to the queue, which delivers by ignoring the
|
||||||
|
destination servers, always connecting to itself instead.
|
||||||
|
|
||||||
|
Recipient addresses with the following localpart suffixes are handled specially:
|
||||||
|
|
||||||
- "temperror": fail with a temporary error code
|
- "temperror": fail with a temporary error code
|
||||||
- "permerror": fail with a permanent error code
|
- "permerror": fail with a permanent error code
|
||||||
|
@ -59,8 +64,7 @@ with:
|
||||||
- "timeout": no response (for an hour)
|
- "timeout": no response (for an hour)
|
||||||
|
|
||||||
If the localpart begins with "mailfrom" or "rcptto", the error is returned
|
If the localpart begins with "mailfrom" or "rcptto", the error is returned
|
||||||
during those commands instead of during "data". If the localpart begins with
|
during those commands instead of during "data".
|
||||||
"queue", the submission is accepted but delivery from the queue will fail.
|
|
||||||
`
|
`
|
||||||
golog.SetFlags(0)
|
golog.SetFlags(0)
|
||||||
|
|
||||||
|
@ -144,6 +148,8 @@ during those commands instead of during "data". If the localpart begins with
|
||||||
smtpserver.Localserve = true
|
smtpserver.Localserve = true
|
||||||
// Tell queue it shouldn't be queuing/delivering.
|
// Tell queue it shouldn't be queuing/delivering.
|
||||||
queue.Localserve = true
|
queue.Localserve = true
|
||||||
|
// Tell DKIM not to fail signatures for TLD localhost.
|
||||||
|
dkim.Localserve = true
|
||||||
|
|
||||||
const mtastsdbRefresher = false
|
const mtastsdbRefresher = false
|
||||||
const sendDMARCReports = false
|
const sendDMARCReports = false
|
||||||
|
@ -393,6 +399,7 @@ func writeLocalConfig(log mlog.Log, dir, ip string) (rerr error) {
|
||||||
Destinations: map[string]config.Destination{
|
Destinations: map[string]config.Destination{
|
||||||
"mox@localhost": {},
|
"mox@localhost": {},
|
||||||
},
|
},
|
||||||
|
NoFirstTimeSenderDelay: true,
|
||||||
}
|
}
|
||||||
acc.AutomaticJunkFlags.Enabled = true
|
acc.AutomaticJunkFlags.Enabled = true
|
||||||
acc.AutomaticJunkFlags.JunkMailboxRegexp = "^(junk|spam)"
|
acc.AutomaticJunkFlags.JunkMailboxRegexp = "^(junk|spam)"
|
||||||
|
@ -408,10 +415,25 @@ func writeLocalConfig(log mlog.Log, dir, ip string) (rerr error) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dkimKeyBuf, err := mox.MakeDKIMEd25519Key(dns.Domain{ASCII: "localserve"}, dns.Domain{ASCII: "localhost"})
|
||||||
|
xcheck(err, "making dkim key")
|
||||||
|
dkimKeyPath := "dkim.localserve.privatekey.pkcs8.pem"
|
||||||
|
err = os.WriteFile(filepath.Join(dir, dkimKeyPath), dkimKeyBuf, 0660)
|
||||||
|
xcheck(err, "writing dkim key file")
|
||||||
|
|
||||||
dynamic := config.Dynamic{
|
dynamic := config.Dynamic{
|
||||||
Domains: map[string]config.Domain{
|
Domains: map[string]config.Domain{
|
||||||
"localhost": {
|
"localhost": {
|
||||||
LocalpartCatchallSeparator: "+",
|
LocalpartCatchallSeparator: "+",
|
||||||
|
DKIM: config.DKIM{
|
||||||
|
Sign: []string{"localserve"},
|
||||||
|
Selectors: map[string]config.Selector{
|
||||||
|
"localserve": {
|
||||||
|
Expiration: "72h",
|
||||||
|
PrivateKeyFile: dkimKeyPath,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Accounts: map[string]config.Account{
|
Accounts: map[string]config.Account{
|
||||||
|
|
|
@ -99,7 +99,7 @@ func dkimKeyNote(kind string, selector, domain dns.Domain) string {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// MakeDKIMEd25519Key returns a PEM buffer containing an rsa key for use with
|
// MakeDKIMRSAKey returns a PEM buffer containing an rsa key for use with
|
||||||
// DKIM.
|
// DKIM.
|
||||||
// selector and domain can be empty. If not, they are used in the note.
|
// selector and domain can be empty. If not, they are used in the note.
|
||||||
func MakeDKIMRSAKey(selector, domain dns.Domain) ([]byte, error) {
|
func MakeDKIMRSAKey(selector, domain dns.Domain) ([]byte, error) {
|
||||||
|
|
103
queue/localserve.go
Normal file
103
queue/localserve.go
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
package queue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mjl-/mox/dns"
|
||||||
|
"github.com/mjl-/mox/dsn"
|
||||||
|
"github.com/mjl-/mox/mlog"
|
||||||
|
"github.com/mjl-/mox/mox-"
|
||||||
|
"github.com/mjl-/mox/smtpclient"
|
||||||
|
"github.com/mjl-/mox/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// We won't be dialing remote servers. We just connect the smtp port of the first
|
||||||
|
// ip in the "local" listener, with fallback to localhost:1025 for any destination
|
||||||
|
// address and try to deliver. Our smtpserver uses a mocked dns resolver to give
|
||||||
|
// spf/dkim a chance to pass.
|
||||||
|
func deliverLocalserve(ctx context.Context, log mlog.Log, msgs []*Msg, backoff time.Duration) {
|
||||||
|
m0 := msgs[0]
|
||||||
|
|
||||||
|
addr := "localhost:1025"
|
||||||
|
l, ok := mox.Conf.Static.Listeners["local"]
|
||||||
|
if ok && l.SMTP.Enabled {
|
||||||
|
port := 1025
|
||||||
|
if l.SMTP.Port != 0 {
|
||||||
|
port = l.SMTP.Port
|
||||||
|
}
|
||||||
|
addr = net.JoinHostPort(l.IPs[0], fmt.Sprintf("%d", port))
|
||||||
|
}
|
||||||
|
var d net.Dialer
|
||||||
|
dialctx, dialcancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
defer dialcancel()
|
||||||
|
conn, err := d.DialContext(dialctx, "tcp", addr)
|
||||||
|
dialcancel()
|
||||||
|
if err != nil {
|
||||||
|
failMsgsDB(log, msgs, m0.DialedIPs, backoff, dsn.NameIP{}, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if conn != nil {
|
||||||
|
err = conn.Close()
|
||||||
|
log.Check(err, "closing connection")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
clientctx, clientcancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
defer clientcancel()
|
||||||
|
localhost := dns.Domain{ASCII: "localhost"}
|
||||||
|
client, err := smtpclient.New(clientctx, log.Logger, conn, smtpclient.TLSOpportunistic, false, localhost, localhost, smtpclient.Opts{})
|
||||||
|
clientcancel()
|
||||||
|
if err != nil {
|
||||||
|
failMsgsDB(log, msgs, m0.DialedIPs, backoff, dsn.NameIP{}, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conn = nil // Will be closed when closing client.
|
||||||
|
defer func() {
|
||||||
|
err := client.Close()
|
||||||
|
log.Check(err, "closing smtp client")
|
||||||
|
}()
|
||||||
|
|
||||||
|
var msgr io.ReadCloser
|
||||||
|
var size int64
|
||||||
|
if len(m0.DSNUTF8) > 0 {
|
||||||
|
msgr = io.NopCloser(bytes.NewReader(m0.DSNUTF8))
|
||||||
|
size = int64(len(m0.DSNUTF8))
|
||||||
|
} else {
|
||||||
|
size = m0.Size
|
||||||
|
p := m0.MessagePath()
|
||||||
|
f, err := os.Open(p)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorx("opening message for delivery", err, slog.String("remote", addr), slog.String("path", p))
|
||||||
|
err = fmt.Errorf("opening message file: %v", err)
|
||||||
|
failMsgsDB(log, msgs, m0.DialedIPs, backoff, dsn.NameIP{}, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msgr = store.FileMsgReader(m0.MsgPrefix, f)
|
||||||
|
defer func() {
|
||||||
|
err := msgr.Close()
|
||||||
|
log.Check(err, "closing message after delivery attempt")
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
deliverctx, delivercancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
defer delivercancel()
|
||||||
|
requireTLS := m0.RequireTLS != nil && *m0.RequireTLS
|
||||||
|
rcpts := make([]string, len(msgs))
|
||||||
|
for i, m := range msgs {
|
||||||
|
rcpts[i] = m.Recipient().String()
|
||||||
|
}
|
||||||
|
rcptErrs, err := client.DeliverMultiple(deliverctx, m0.Sender().String(), rcpts, size, msgr, m0.Has8bit, m0.SMTPUTF8, requireTLS)
|
||||||
|
delivercancel()
|
||||||
|
if err != nil {
|
||||||
|
log.Infox("smtp transaction for delivery failed", err)
|
||||||
|
}
|
||||||
|
processDeliveries(log, m0, msgs, addr, "localhost", backoff, rcptErrs, err)
|
||||||
|
}
|
114
queue/queue.go
114
queue/queue.go
|
@ -28,12 +28,10 @@ import (
|
||||||
"github.com/mjl-/mox/config"
|
"github.com/mjl-/mox/config"
|
||||||
"github.com/mjl-/mox/dns"
|
"github.com/mjl-/mox/dns"
|
||||||
"github.com/mjl-/mox/dsn"
|
"github.com/mjl-/mox/dsn"
|
||||||
"github.com/mjl-/mox/message"
|
|
||||||
"github.com/mjl-/mox/metrics"
|
"github.com/mjl-/mox/metrics"
|
||||||
"github.com/mjl-/mox/mlog"
|
"github.com/mjl-/mox/mlog"
|
||||||
"github.com/mjl-/mox/mox-"
|
"github.com/mjl-/mox/mox-"
|
||||||
"github.com/mjl-/mox/moxio"
|
"github.com/mjl-/mox/moxio"
|
||||||
"github.com/mjl-/mox/publicsuffix"
|
|
||||||
"github.com/mjl-/mox/smtp"
|
"github.com/mjl-/mox/smtp"
|
||||||
"github.com/mjl-/mox/smtpclient"
|
"github.com/mjl-/mox/smtpclient"
|
||||||
"github.com/mjl-/mox/store"
|
"github.com/mjl-/mox/store"
|
||||||
|
@ -1484,117 +1482,7 @@ func deliver(log mlog.Log, resolver dns.Resolver, m0 Msg) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if Localserve {
|
if Localserve {
|
||||||
// We are not actually going to deliver. We'll deliver to the sender account.
|
deliverLocalserve(ctx, qlog, msgs, backoff)
|
||||||
// Unless recipients match certain special patterns, in which case we can pretend
|
|
||||||
// to cause delivery failures. Useful for testing.
|
|
||||||
|
|
||||||
acc, err := store.OpenAccount(log, m0.SenderAccount)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorx("opening sender account for immediate delivery with localserve, skipping", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
err := acc.Close()
|
|
||||||
log.Check(err, "closing account")
|
|
||||||
}()
|
|
||||||
conf, _ := acc.Conf()
|
|
||||||
|
|
||||||
p := m0.MessagePath()
|
|
||||||
msgFile, err := os.Open(p)
|
|
||||||
if err != nil {
|
|
||||||
xerr := fmt.Errorf("open message for delivery: %v", err)
|
|
||||||
failMsgsDB(qlog, msgs, m0.DialedIPs, backoff, dsn.NameIP{}, xerr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
err := msgFile.Close()
|
|
||||||
qlog.Check(err, "closing message after delivery attempt")
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Parse the message for a From-address, but continue on error.
|
|
||||||
fromAddr, _, _, fromErr := message.From(qlog.Logger, false, store.FileMsgReader(m0.MsgPrefix, msgFile), nil)
|
|
||||||
log.Check(fromErr, "parsing message From header")
|
|
||||||
|
|
||||||
for _, qm := range msgs {
|
|
||||||
code, timeout := mox.LocalserveNeedsError(qm.RecipientLocalpart)
|
|
||||||
if timeout || code != 0 {
|
|
||||||
err := errors.New("simulated error due to localserve mode and special recipient localpart")
|
|
||||||
if timeout {
|
|
||||||
err = fmt.Errorf("%s: timeout", err)
|
|
||||||
} else {
|
|
||||||
err = smtpclient.Error{Permanent: code/100 == 5, Code: code, Err: err}
|
|
||||||
}
|
|
||||||
failMsgsDB(qlog, []*Msg{qm}, m0.DialedIPs, backoff, remoteMTA, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
msgFromOrgDomain := publicsuffix.Lookup(ctx, qlog.Logger, fromAddr.Domain)
|
|
||||||
|
|
||||||
dm := store.Message{
|
|
||||||
RemoteIP: "::1",
|
|
||||||
RemoteIPMasked1: "::",
|
|
||||||
RemoteIPMasked2: "::",
|
|
||||||
RemoteIPMasked3: "::",
|
|
||||||
MailFrom: qm.Sender().XString(true),
|
|
||||||
MailFromLocalpart: qm.SenderLocalpart,
|
|
||||||
MailFromDomain: qm.SenderDomainStr,
|
|
||||||
RcptToLocalpart: qm.RecipientLocalpart,
|
|
||||||
RcptToDomain: qm.RecipientDomainStr,
|
|
||||||
MsgFromLocalpart: fromAddr.Localpart,
|
|
||||||
MsgFromDomain: fromAddr.Domain.Name(),
|
|
||||||
MsgFromOrgDomain: msgFromOrgDomain.Name(),
|
|
||||||
EHLOValidated: true,
|
|
||||||
MailFromValidated: true,
|
|
||||||
MsgFromValidated: true,
|
|
||||||
EHLOValidation: store.ValidationPass,
|
|
||||||
MailFromValidation: store.ValidationPass,
|
|
||||||
MsgFromValidation: store.ValidationDMARC,
|
|
||||||
DKIMDomains: []string{"localhost"},
|
|
||||||
ReceivedRequireTLS: qm.RequireTLS != nil && *qm.RequireTLS,
|
|
||||||
Size: qm.Size,
|
|
||||||
MsgPrefix: qm.MsgPrefix,
|
|
||||||
}
|
|
||||||
var err error
|
|
||||||
var mb store.Mailbox
|
|
||||||
acc.WithWLock(func() {
|
|
||||||
dest := conf.Destinations[qm.Recipient().String()]
|
|
||||||
err = acc.DeliverDestination(log, dest, &dm, msgFile)
|
|
||||||
if err != nil {
|
|
||||||
err = fmt.Errorf("delivering message: %v", err)
|
|
||||||
return // Returned again outside WithWLock.
|
|
||||||
}
|
|
||||||
|
|
||||||
mb = store.Mailbox{ID: dm.MailboxID}
|
|
||||||
if err = acc.DB.Get(context.Background(), &mb); err != nil {
|
|
||||||
err = fmt.Errorf("getting mailbox for message after delivery: %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.Errorx("delivering from queue to original sender account failed, skipping", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
log.Debug("delivered from queue to original sender account")
|
|
||||||
qm.markResult(0, "", "", true)
|
|
||||||
err = DB.Write(context.Background(), func(tx *bstore.Tx) error {
|
|
||||||
return retireMsgs(qlog, tx, webhook.EventDelivered, smtp.C250Completed, "", nil, *qm)
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.Errorx("removing queue message from database after local delivery to sender account", err)
|
|
||||||
} else if err := removeMsgsFS(qlog, *qm); err != nil {
|
|
||||||
log.Errorx("removing queue messages from file system after local delivery to sender account", err)
|
|
||||||
}
|
|
||||||
kick()
|
|
||||||
|
|
||||||
// Process incoming message for incoming webhook.
|
|
||||||
mr := store.FileMsgReader(dm.MsgPrefix, msgFile)
|
|
||||||
part, err := dm.LoadPart(mr)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorx("loading parsed part for evaluating webhook", err)
|
|
||||||
} else {
|
|
||||||
err = Incoming(context.Background(), log, acc, m0.MessageID, dm, part, mb.Name)
|
|
||||||
log.Check(err, "queueing webhook for incoming delivery")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1284,84 +1284,6 @@ func TestQueueStart(t *testing.T) {
|
||||||
time.Sleep(100 * time.Millisecond) // Racy... give time to finish.
|
time.Sleep(100 * time.Millisecond) // Racy... give time to finish.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Localserve should cause deliveries to go to sender account, with failure (DSN)
|
|
||||||
// for recipient addresses that start with "queue" and end with
|
|
||||||
// "temperror"/"permerror"/"timeout".
|
|
||||||
func TestLocalserve(t *testing.T) {
|
|
||||||
Localserve = true
|
|
||||||
defer func() {
|
|
||||||
Localserve = false
|
|
||||||
}()
|
|
||||||
|
|
||||||
path := smtp.Path{Localpart: "mjl", IPDomain: dns.IPDomain{Domain: dns.Domain{ASCII: "mox.example"}}}
|
|
||||||
|
|
||||||
testDeliver := func(to smtp.Path, expSuccess bool) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
_, cleanup := setup(t)
|
|
||||||
defer cleanup()
|
|
||||||
err := Init()
|
|
||||||
tcheck(t, err, "queue init")
|
|
||||||
|
|
||||||
accret, err := store.OpenAccount(pkglog, "retired")
|
|
||||||
tcheck(t, err, "open account")
|
|
||||||
defer func() {
|
|
||||||
err := accret.Close()
|
|
||||||
tcheck(t, err, "closing account")
|
|
||||||
accret.CheckClosed()
|
|
||||||
}()
|
|
||||||
|
|
||||||
mf := prepareFile(t)
|
|
||||||
defer os.Remove(mf.Name())
|
|
||||||
defer mf.Close()
|
|
||||||
|
|
||||||
// Regular message.
|
|
||||||
qm := MakeMsg(path, to, false, false, int64(len(testmsg)), "<test@localhost>", nil, nil, time.Now(), "test")
|
|
||||||
qml := []Msg{qm}
|
|
||||||
err = Add(ctxbg, pkglog, accret.Name, mf, qml...)
|
|
||||||
tcheck(t, err, "add message to queue")
|
|
||||||
qm = qml[0]
|
|
||||||
|
|
||||||
deliver(pkglog, nil, qm)
|
|
||||||
<-deliveryResults
|
|
||||||
// Message should be delivered to account.
|
|
||||||
n, err := bstore.QueryDB[store.Message](ctxbg, accret.DB).Count()
|
|
||||||
tcheck(t, err, "count messages in account")
|
|
||||||
tcompare(t, n, 1)
|
|
||||||
|
|
||||||
n, err = Count(ctxbg)
|
|
||||||
tcheck(t, err, "count message queue")
|
|
||||||
tcompare(t, n, 0)
|
|
||||||
|
|
||||||
_, err = bstore.QueryDB[MsgRetired](ctxbg, DB).Count()
|
|
||||||
tcheck(t, err, "get retired message")
|
|
||||||
|
|
||||||
hl, err := bstore.QueryDB[Hook](ctxbg, DB).List()
|
|
||||||
tcheck(t, err, "get webhooks")
|
|
||||||
if expSuccess {
|
|
||||||
tcompare(t, len(hl), 2)
|
|
||||||
tcompare(t, hl[0].IsIncoming, false)
|
|
||||||
tcompare(t, hl[1].IsIncoming, true)
|
|
||||||
} else {
|
|
||||||
tcompare(t, len(hl), 1)
|
|
||||||
tcompare(t, hl[0].IsIncoming, false)
|
|
||||||
}
|
|
||||||
var out webhook.Outgoing
|
|
||||||
err = json.Unmarshal([]byte(hl[0].Payload), &out)
|
|
||||||
tcheck(t, err, "unmarshal outgoing webhook payload")
|
|
||||||
if expSuccess {
|
|
||||||
tcompare(t, out.Event, webhook.EventDelivered)
|
|
||||||
} else {
|
|
||||||
tcompare(t, out.Event, webhook.EventFailed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
testDeliver(path, true)
|
|
||||||
badpath := path
|
|
||||||
badpath.Localpart = smtp.Localpart("queuepermerror")
|
|
||||||
testDeliver(badpath, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListFilterSort(t *testing.T) {
|
func TestListFilterSort(t *testing.T) {
|
||||||
_, cleanup := setup(t)
|
_, cleanup := setup(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
|
@ -231,7 +231,14 @@ func deliverSubmit(qlog mlog.Log, resolver dns.Resolver, dialer smtpclient.Diale
|
||||||
if submiterr != nil {
|
if submiterr != nil {
|
||||||
qlog.Infox("smtp transaction for delivery failed", submiterr)
|
qlog.Infox("smtp transaction for delivery failed", submiterr)
|
||||||
}
|
}
|
||||||
failed = 0 // Reset, we are looking at the SMTP results below.
|
failed, delivered = processDeliveries(qlog, m0, msgs, addr, transport.Host, backoff, rcptErrs, submiterr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process failures and successful deliveries, retiring/removing messages from
|
||||||
|
// queue, queueing webhooks.
|
||||||
|
//
|
||||||
|
// Also used by deliverLocalserve.
|
||||||
|
func processDeliveries(qlog mlog.Log, m0 *Msg, msgs []*Msg, remoteAddr string, remoteHost string, backoff time.Duration, rcptErrs []smtpclient.Response, submiterr error) (failed, delivered int) {
|
||||||
var delMsgs []Msg
|
var delMsgs []Msg
|
||||||
for i, m := range msgs {
|
for i, m := range msgs {
|
||||||
qmlog := qlog.With(
|
qmlog := qlog.With(
|
||||||
|
@ -246,14 +253,14 @@ func deliverSubmit(qlog mlog.Log, resolver dns.Resolver, dialer smtpclient.Diale
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
smtperr, ok := err.(smtpclient.Error)
|
smtperr, ok := err.(smtpclient.Error)
|
||||||
err = fmt.Errorf("transport %s: submitting message to %s: %w", transportName, addr, err)
|
err = fmt.Errorf("delivering message to %s: %w", remoteAddr, err)
|
||||||
var remoteMTA dsn.NameIP
|
var remoteMTA dsn.NameIP
|
||||||
if ok {
|
if ok {
|
||||||
remoteMTA.Name = transport.Host
|
remoteMTA.Name = remoteHost
|
||||||
smtperr.Err = err
|
smtperr.Err = err
|
||||||
err = smtperr
|
err = smtperr
|
||||||
}
|
}
|
||||||
qmlog.Errorx("submitting message", err, slog.String("remote", addr))
|
qmlog.Errorx("submitting message", err, slog.String("remote", remoteAddr))
|
||||||
failMsgsDB(qmlog, []*Msg{m}, m0.DialedIPs, backoff, remoteMTA, err)
|
failMsgsDB(qmlog, []*Msg{m}, m0.DialedIPs, backoff, remoteMTA, err)
|
||||||
failed++
|
failed++
|
||||||
} else {
|
} else {
|
||||||
|
@ -274,4 +281,5 @@ func deliverSubmit(qlog mlog.Log, resolver dns.Resolver, dialer smtpclient.Diale
|
||||||
}
|
}
|
||||||
kick()
|
kick()
|
||||||
}
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/ed25519"
|
||||||
"crypto/md5"
|
"crypto/md5"
|
||||||
cryptorand "crypto/rand"
|
cryptorand "crypto/rand"
|
||||||
"crypto/rsa"
|
"crypto/rsa"
|
||||||
|
@ -1617,18 +1618,11 @@ func (c *conn) cmdRcpt(p *parser) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if Localserve {
|
if Localserve && strings.HasPrefix(string(fpath.Localpart), "rcptto") {
|
||||||
if strings.HasPrefix(string(fpath.Localpart), "rcptto") {
|
c.xlocalserveError(fpath.Localpart)
|
||||||
c.xlocalserveError(fpath.Localpart)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// If account or destination doesn't exist, it will be handled during delivery. For
|
if len(fpath.IPDomain.IP) > 0 {
|
||||||
// submissions, which is the common case, we'll deliver to the logged in user,
|
|
||||||
// which is typically the mox user.
|
|
||||||
acc, _ := mox.Conf.Account("mox")
|
|
||||||
dest := acc.Destinations["mox@localhost"]
|
|
||||||
c.recipients = append(c.recipients, rcptAccount{fpath, true, "mox", dest, "mox@localhost"})
|
|
||||||
} else if len(fpath.IPDomain.IP) > 0 {
|
|
||||||
if !c.submission {
|
if !c.submission {
|
||||||
xsmtpUserErrorf(smtp.C550MailboxUnavail, smtp.SeAddr1UnknownDestMailbox1, "not accepting email for ip")
|
xsmtpUserErrorf(smtp.C550MailboxUnavail, smtp.SeAddr1UnknownDestMailbox1, "not accepting email for ip")
|
||||||
}
|
}
|
||||||
|
@ -1636,6 +1630,14 @@ func (c *conn) cmdRcpt(p *parser) {
|
||||||
} else if accountName, canonical, addr, err := mox.FindAccount(fpath.Localpart, fpath.IPDomain.Domain, true); err == nil {
|
} else if accountName, canonical, addr, err := mox.FindAccount(fpath.Localpart, fpath.IPDomain.Domain, true); err == nil {
|
||||||
// note: a bare postmaster, without domain, is handled by FindAccount. ../rfc/5321:735
|
// note: a bare postmaster, without domain, is handled by FindAccount. ../rfc/5321:735
|
||||||
c.recipients = append(c.recipients, rcptAccount{fpath, true, accountName, addr, canonical})
|
c.recipients = append(c.recipients, rcptAccount{fpath, true, accountName, addr, canonical})
|
||||||
|
} else if Localserve {
|
||||||
|
// If the address isn't known, and we are in localserve, deliver to the mox user.
|
||||||
|
// If account or destination doesn't exist, it will be handled during delivery. For
|
||||||
|
// submissions, which is the common case, we'll deliver to the logged in user,
|
||||||
|
// which is typically the mox user.
|
||||||
|
acc, _ := mox.Conf.Account("mox")
|
||||||
|
dest := acc.Destinations["mox@localhost"]
|
||||||
|
c.recipients = append(c.recipients, rcptAccount{fpath, true, "mox", dest, "mox@localhost"})
|
||||||
} else if errors.Is(err, mox.ErrDomainNotFound) {
|
} else if errors.Is(err, mox.ErrDomainNotFound) {
|
||||||
if !c.submission {
|
if !c.submission {
|
||||||
xsmtpUserErrorf(smtp.C550MailboxUnavail, smtp.SeAddr1UnknownDestMailbox1, "not accepting email for domain")
|
xsmtpUserErrorf(smtp.C550MailboxUnavail, smtp.SeAddr1UnknownDestMailbox1, "not accepting email for domain")
|
||||||
|
@ -1999,7 +2001,7 @@ func (c *conn) submit(ctx context.Context, recvHdrFor func(string) string, msgWr
|
||||||
msgPrefix = append(msgPrefix, "Date: "+time.Now().Format(message.RFC5322Z)+"\r\n"...)
|
msgPrefix = append(msgPrefix, "Date: "+time.Now().Format(message.RFC5322Z)+"\r\n"...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check outoging message rate limit.
|
// Check outgoing message rate limit.
|
||||||
err = c.account.DB.Read(ctx, func(tx *bstore.Tx) error {
|
err = c.account.DB.Read(ctx, func(tx *bstore.Tx) error {
|
||||||
rcpts := make([]smtp.Path, len(c.recipients))
|
rcpts := make([]smtp.Path, len(c.recipients))
|
||||||
for i, r := range c.recipients {
|
for i, r := range c.recipients {
|
||||||
|
@ -2286,7 +2288,34 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW
|
||||||
dkimctx, dkimcancel := context.WithTimeout(ctx, time.Minute)
|
dkimctx, dkimcancel := context.WithTimeout(ctx, time.Minute)
|
||||||
defer dkimcancel()
|
defer dkimcancel()
|
||||||
// todo future: we could let user configure which dkim headers they require
|
// todo future: we could let user configure which dkim headers they require
|
||||||
dkimResults, dkimErr = dkim.Verify(dkimctx, c.log.Logger, c.resolver, c.msgsmtputf8, dkim.DefaultPolicy, dataFile, ignoreTestMode)
|
|
||||||
|
// For localserve, fake dkim selector DNS records for hosted domains to give
|
||||||
|
// dkim-signatures a chance to pass for deliveries from queue.
|
||||||
|
resolver := c.resolver
|
||||||
|
if Localserve {
|
||||||
|
// Lookup based on message From address is an approximation.
|
||||||
|
if dc, ok := mox.Conf.Domain(msgFrom.Domain); ok && len(dc.DKIM.Selectors) > 0 {
|
||||||
|
txts := map[string][]string{}
|
||||||
|
for name, sel := range dc.DKIM.Selectors {
|
||||||
|
dkimr := dkim.Record{
|
||||||
|
Version: "DKIM1",
|
||||||
|
Hashes: []string{sel.HashEffective},
|
||||||
|
PublicKey: sel.Key.Public(),
|
||||||
|
}
|
||||||
|
if _, ok := sel.Key.(ed25519.PrivateKey); ok {
|
||||||
|
dkimr.Key = "ed25519"
|
||||||
|
} else if _, ok := sel.Key.(*rsa.PrivateKey); !ok {
|
||||||
|
err := fmt.Errorf("unrecognized private key for DKIM selector %q: %T", name, sel.Key)
|
||||||
|
xcheckf(err, "making dkim record")
|
||||||
|
}
|
||||||
|
txt, err := dkimr.Record()
|
||||||
|
xcheckf(err, "making DKIM DNS TXT record")
|
||||||
|
txts[name+"._domainkey."+msgFrom.Domain.ASCII+"."] = []string{txt}
|
||||||
|
}
|
||||||
|
resolver = dns.MockResolver{TXT: txts}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dkimResults, dkimErr = dkim.Verify(dkimctx, c.log.Logger, resolver, c.msgsmtputf8, dkim.DefaultPolicy, dataFile, ignoreTestMode)
|
||||||
dkimcancel()
|
dkimcancel()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
@ -2318,7 +2347,17 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
spfctx, spfcancel := context.WithTimeout(ctx, time.Minute)
|
spfctx, spfcancel := context.WithTimeout(ctx, time.Minute)
|
||||||
defer spfcancel()
|
defer spfcancel()
|
||||||
receivedSPF, spfDomain, spfExpl, spfAuthentic, spfErr = spf.Verify(spfctx, c.log.Logger, c.resolver, spfArgs)
|
resolver := c.resolver
|
||||||
|
// For localserve, give hosted domains a chance to pass for deliveries from queue.
|
||||||
|
if Localserve && c.remoteIP.IsLoopback() {
|
||||||
|
// Lookup based on message From address is an approximation.
|
||||||
|
if _, ok := mox.Conf.Domain(msgFrom.Domain); ok {
|
||||||
|
resolver = dns.MockResolver{
|
||||||
|
TXT: map[string][]string{msgFrom.Domain.ASCII + ".": {"v=spf1 ip4:127.0.0.1/8 ip6:::1 ~all"}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
receivedSPF, spfDomain, spfExpl, spfAuthentic, spfErr = spf.Verify(spfctx, c.log.Logger, resolver, spfArgs)
|
||||||
spfcancel()
|
spfcancel()
|
||||||
if spfErr != nil {
|
if spfErr != nil {
|
||||||
c.log.Infox("spf verify", spfErr)
|
c.log.Infox("spf verify", spfErr)
|
||||||
|
@ -3001,7 +3040,7 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if Localserve && !strings.HasPrefix(string(rcptAcc.rcptTo.Localpart), "queue") {
|
if Localserve {
|
||||||
code, timeout := mox.LocalserveNeedsError(rcptAcc.rcptTo.Localpart)
|
code, timeout := mox.LocalserveNeedsError(rcptAcc.rcptTo.Localpart)
|
||||||
if timeout {
|
if timeout {
|
||||||
log.Info("timing out due to special localpart")
|
log.Info("timing out due to special localpart")
|
||||||
|
|
Loading…
Reference in a new issue