Skip to content

Commit

Permalink
add config option to an account destination to reject messages that d…
Browse files Browse the repository at this point in the history
…on't pass a dmarc-like aligned spf/aligned dkim check

intended for automated processors that don't want to send messages to senders
without verified domains (because the address may be forged, and the processor
doesn't want to bother innocent bystanders).

such delivery attempts will fail with a permanent error immediately, typically
resulting in a DSN message to the original sender. the configurable error
message will normally be included in the DSN, so it could have alternative
instructions.
  • Loading branch information
mjl- committed Feb 15, 2025
1 parent f33870b commit 6da5f8f
Show file tree
Hide file tree
Showing 13 changed files with 108 additions and 9 deletions.
9 changes: 5 additions & 4 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -458,10 +458,11 @@ type JunkFilter struct {
}

type Destination struct {
Mailbox string `sconf:"optional" sconf-doc:"Mailbox to deliver to if none of Rulesets match. Default: Inbox."`
Rulesets []Ruleset `sconf:"optional" sconf-doc:"Delivery rules based on message and SMTP transaction. You may want to match each mailing list by SMTP MailFrom address, VerifiedDomain and/or List-ID header (typically <listname.example.org> if the list address is listname@example.org), delivering them to their own mailbox."`
SMTPError string `sconf:"optional" sconf-doc:"If non-empty, incoming delivery attempts to this destination will be rejected during SMTP RCPT TO with this error response line. Useful when a catchall address is configured for the domain and messages to some addresses should be rejected. The response line must start with an error code. Currently the following error resonse codes are allowed: 421 (temporary local error), 550 (user not found). If the line consists of only an error code, an appropriate error message is added. Rejecting messages with a 4xx code invites later retries by the remote, while 5xx codes should prevent further delivery attempts."`
FullName string `sconf:"optional" sconf-doc:"Full name to use in message From header when composing messages coming from this address with webmail."`
Mailbox string `sconf:"optional" sconf-doc:"Mailbox to deliver to if none of Rulesets match. Default: Inbox."`
Rulesets []Ruleset `sconf:"optional" sconf-doc:"Delivery rules based on message and SMTP transaction. You may want to match each mailing list by SMTP MailFrom address, VerifiedDomain and/or List-ID header (typically <listname.example.org> if the list address is listname@example.org), delivering them to their own mailbox."`
SMTPError string `sconf:"optional" sconf-doc:"If non-empty, incoming delivery attempts to this destination will be rejected during SMTP RCPT TO with this error response line. Useful when a catchall address is configured for the domain and messages to some addresses should be rejected. The response line must start with an error code. Currently the following error resonse codes are allowed: 421 (temporary local error), 550 (user not found). If the line consists of only an error code, an appropriate error message is added. Rejecting messages with a 4xx code invites later retries by the remote, while 5xx codes should prevent further delivery attempts."`
MessageAuthRequiredSMTPError string `sconf:"optional" sconf-doc:"If non-empty, an additional DMARC-like message authentication check is done for incoming messages, validating the domain in the From-header of the message. Messages without either an aligned SPF or aligned DKIM pass are rejected during the SMTP DATA command with a permanent error code followed by the message in this field. The domain in the message 'From' header is matched in relaxed or strict mode according to the domain's DMARC policy if present, or relaxed mode (organizational instead of exact domain match) otherwise. Useful for autoresponders that don't want to accept messages they don't want to send an automated reply to."`
FullName string `sconf:"optional" sconf-doc:"Full name to use in message From header when composing messages coming from this address with webmail."`

DMARCReports bool `sconf:"-" json:"-"`
HostTLSReports bool `sconf:"-" json:"-"`
Expand Down
11 changes: 11 additions & 0 deletions config/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -1150,6 +1150,17 @@ See https://pkg.go.dev/github.com/mjl-/sconf for details.
# (optional)
SMTPError:
# If non-empty, an additional DMARC-like message authentication check is done for
# incoming messages, validating the domain in the From-header of the message.
# Messages without either an aligned SPF or aligned DKIM pass are rejected during
# the SMTP DATA command with a permanent error code followed by the message in
# this field. The domain in the message 'From' header is matched in relaxed or
# strict mode according to the domain's DMARC policy if present, or relaxed mode
# (organizational instead of exact domain match) otherwise. Useful for
# autoresponders that don't want to accept messages they don't want to send an
# automated reply to. (optional)
MessageAuthRequiredSMTPError:
# Full name to use in message From header when composing messages coming from this
# address with webmail. (optional)
FullName:
Expand Down
12 changes: 12 additions & 0 deletions mox-/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -1512,6 +1512,18 @@ func prepareDynamicConfig(ctx context.Context, log mlog.Log, dynamicPath string,
acc.Destinations[addrName] = dest
}

if dest.MessageAuthRequiredSMTPError != "" {
if len(dest.MessageAuthRequiredSMTPError) > 256 {
addDestErrorf("message authentication required smtp error must be smaller than 256 bytes")
}
for _, c := range dest.MessageAuthRequiredSMTPError {
if c < ' ' || c >= 0x7f {
addDestErrorf("message authentication required smtp error cannot contain contain control characters (including newlines) or non-ascii")
break
}
}
}

for i, rs := range dest.Rulesets {
addRulesetErrorf := func(format string, args ...any) {
addDestErrorf("ruleset %d: %s", i+1, fmt.Sprintf(format, args...))
Expand Down
14 changes: 14 additions & 0 deletions smtpserver/analyze.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ const (
reasonSubjectpassError = "subjectpass-error"
reasonIPrev = "iprev" // No or mild junk reputation signals, and bad iprev.
reasonHighRate = "high-rate" // Too many messages, not added to rejects.
reasonMsgAuthRequired = "msg-auth-required"
)

func isListDomain(d delivery, ld dns.Domain) bool {
Expand Down Expand Up @@ -396,6 +397,19 @@ func analyze(ctx context.Context, log mlog.Log, resolver dns.Resolver, d deliver
}
}

// We may have to reject messages that don't pass a relaxed aligned SPF and/or DKIM
// check. Useful for services with autoresponders.
if d.destination.MessageAuthRequiredSMTPError != "" && !d.m.MsgFromValidated {
code := smtp.C550MailboxUnavail
msg := d.destination.MessageAuthRequiredSMTPError
if d.dmarcResult.Status == dmarc.StatusTemperror {
code = smtp.C451LocalErr
msg = "transient verification error: " + msg
}
addReasonText("message does not pass required aligned spf and/or dkim check required for destination")
return reject(code, smtp.SePol7MultiAuthFails26, msg, nil, reasonMsgAuthRequired)
}

// Determine if message is acceptable based on DMARC domain, DKIM identities, or
// host-based reputation.
var isjunk *bool
Expand Down
33 changes: 33 additions & 0 deletions smtpserver/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2038,3 +2038,36 @@ func TestDestinationSMTPError(t *testing.T) {
ts.smtpErr(err, &smtpclient.Error{Permanent: true, Code: smtp.C550MailboxUnavail, Secode: smtp.SeAddr1UnknownDestMailbox1})
})
}

// TestDestinationMessageAuthRequiredSMTPError checks delivery to a destination
// with an MessageAuthRequiredSMTPError is accepted/rejected as configured.
func TestDestinationMessageAuthRequiredSMTPError(t *testing.T) {
resolver := dns.MockResolver{
A: map[string][]string{
"example.org.": {"127.0.0.10"}, // For mx check.
},
PTR: map[string][]string{
"127.0.0.10": {"example.org."},
},
TXT: map[string][]string{},
}

ts := newTestServer(t, filepath.FromSlash("../testdata/smtp/mox.conf"), resolver)
defer ts.close()

ts.run(func(client *smtpclient.Client) {
mailFrom := "mjl@example.org"
rcptTo := "msgauthrequired@mox.example"
err := client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false, false)
ts.smtpErr(err, &smtpclient.Error{Permanent: true, Code: smtp.C550MailboxUnavail, Secode: smtp.SePol7MultiAuthFails26})
})

// Ensure SPF pass, message should now be accepted.
resolver.TXT["example.org."] = []string{"v=spf1 ip4:127.0.0.10 -all"}
ts.run(func(client *smtpclient.Client) {
mailFrom := "mjl@example.org"
rcptTo := "msgauthrequired@mox.example"
err := client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false, false)
ts.smtpErr(err, nil)
})
}
2 changes: 2 additions & 0 deletions testdata/smtp/domains.conf
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ Accounts:
móx@mox.example: nil
blocked@mox.example:
SMTPError: 550 no more messages
msgauthrequired@mox.example:
MessageAuthRequiredSMTPError: cannot authenticate domain in message-from header, ensure aligned spf/dkim pass
mjl@disabled.example: nil
JunkFilter:
Threshold: 0.9
Expand Down
6 changes: 4 additions & 2 deletions webaccount/account.js

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions webaccount/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1824,6 +1824,7 @@ const destination = async (name: string) => {
let defaultMailbox: HTMLInputElement
let fullName: HTMLInputElement
let smtpError: HTMLInputElement
let msgAuthRequiredSMTPError: HTMLInputElement
let saveButton: HTMLButtonElement

const addresses = [name, ...Object.keys(acc.Destinations || {}).filter(a => !a.startsWith('@') && a !== name)]
Expand Down Expand Up @@ -1851,6 +1852,12 @@ const destination = async (name: string) => {
smtpError=dom.input(attr.value(dest.SMTPError), attr.placeholder('421 or 550...')),
),
dom.br(),
dom.div(
dom.span('Reject messages without authenticated domain (aligned SPF/DKIM)', attr.title("If non-empty, an additional DMARC-like message authentication check is done for incoming messages, validating the domain in the From-header of the message. Messages without either an aligned SPF or aligned DKIM pass are rejected during the SMTP DATA command with a permanent error code followed by the message in this field. The domain in the message 'From' header is matched in relaxed or strict mode according to the domain's DMARC policy if present, or relaxed mode (organizational instead of exact domain match) otherwise. Useful for autoresponders that don't want to accept messages they don't want to send an automated reply to.")),
dom.br(),
msgAuthRequiredSMTPError=dom.input(attr.value(dest.MessageAuthRequiredSMTPError), attr.placeholder('messages must have aligned spf/dkim for domain authentication...')),
),
dom.br(),

dom.h2('Rulesets'),
dom.p('Incoming messages are checked against the rulesets. If a ruleset matches, the message is delivered to the mailbox configured for the ruleset instead of to the default mailbox.'),
Expand Down Expand Up @@ -1917,6 +1924,7 @@ const destination = async (name: string) => {
}
}),
SMTPError: smtpError.value,
MessageAuthRequiredSMTPError: msgAuthRequiredSMTPError.value,
}
await check(saveButton, client.DestinationSave(name, dest, newDest))
window.location.reload() // todo: only refresh part of ui
Expand Down
7 changes: 7 additions & 0 deletions webaccount/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -814,6 +814,13 @@
"string"
]
},
{
"Name": "MessageAuthRequiredSMTPError",
"Docs": "",
"Typewords": [
"string"
]
},
{
"Name": "FullName",
"Docs": "",
Expand Down
3 changes: 2 additions & 1 deletion webaccount/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export interface Destination {
Mailbox: string
Rulesets?: Ruleset[] | null
SMTPError: string
MessageAuthRequiredSMTPError: string
FullName: string
}

Expand Down Expand Up @@ -298,7 +299,7 @@ export const types: TypenameMap = {
"Account": {"Name":"Account","Docs":"","Fields":[{"Name":"OutgoingWebhook","Docs":"","Typewords":["nullable","OutgoingWebhook"]},{"Name":"IncomingWebhook","Docs":"","Typewords":["nullable","IncomingWebhook"]},{"Name":"FromIDLoginAddresses","Docs":"","Typewords":["[]","string"]},{"Name":"KeepRetiredMessagePeriod","Docs":"","Typewords":["int64"]},{"Name":"KeepRetiredWebhookPeriod","Docs":"","Typewords":["int64"]},{"Name":"LoginDisabled","Docs":"","Typewords":["string"]},{"Name":"Domain","Docs":"","Typewords":["string"]},{"Name":"Description","Docs":"","Typewords":["string"]},{"Name":"FullName","Docs":"","Typewords":["string"]},{"Name":"Destinations","Docs":"","Typewords":["{}","Destination"]},{"Name":"SubjectPass","Docs":"","Typewords":["SubjectPass"]},{"Name":"QuotaMessageSize","Docs":"","Typewords":["int64"]},{"Name":"RejectsMailbox","Docs":"","Typewords":["string"]},{"Name":"KeepRejects","Docs":"","Typewords":["bool"]},{"Name":"AutomaticJunkFlags","Docs":"","Typewords":["AutomaticJunkFlags"]},{"Name":"JunkFilter","Docs":"","Typewords":["nullable","JunkFilter"]},{"Name":"MaxOutgoingMessagesPerDay","Docs":"","Typewords":["int32"]},{"Name":"MaxFirstTimeRecipientsPerDay","Docs":"","Typewords":["int32"]},{"Name":"NoFirstTimeSenderDelay","Docs":"","Typewords":["bool"]},{"Name":"NoCustomPassword","Docs":"","Typewords":["bool"]},{"Name":"Routes","Docs":"","Typewords":["[]","Route"]},{"Name":"DNSDomain","Docs":"","Typewords":["Domain"]},{"Name":"Aliases","Docs":"","Typewords":["[]","AddressAlias"]}]},
"OutgoingWebhook": {"Name":"OutgoingWebhook","Docs":"","Fields":[{"Name":"URL","Docs":"","Typewords":["string"]},{"Name":"Authorization","Docs":"","Typewords":["string"]},{"Name":"Events","Docs":"","Typewords":["[]","string"]}]},
"IncomingWebhook": {"Name":"IncomingWebhook","Docs":"","Fields":[{"Name":"URL","Docs":"","Typewords":["string"]},{"Name":"Authorization","Docs":"","Typewords":["string"]}]},
"Destination": {"Name":"Destination","Docs":"","Fields":[{"Name":"Mailbox","Docs":"","Typewords":["string"]},{"Name":"Rulesets","Docs":"","Typewords":["[]","Ruleset"]},{"Name":"SMTPError","Docs":"","Typewords":["string"]},{"Name":"FullName","Docs":"","Typewords":["string"]}]},
"Destination": {"Name":"Destination","Docs":"","Fields":[{"Name":"Mailbox","Docs":"","Typewords":["string"]},{"Name":"Rulesets","Docs":"","Typewords":["[]","Ruleset"]},{"Name":"SMTPError","Docs":"","Typewords":["string"]},{"Name":"MessageAuthRequiredSMTPError","Docs":"","Typewords":["string"]},{"Name":"FullName","Docs":"","Typewords":["string"]}]},
"Ruleset": {"Name":"Ruleset","Docs":"","Fields":[{"Name":"SMTPMailFromRegexp","Docs":"","Typewords":["string"]},{"Name":"MsgFromRegexp","Docs":"","Typewords":["string"]},{"Name":"VerifiedDomain","Docs":"","Typewords":["string"]},{"Name":"HeadersRegexp","Docs":"","Typewords":["{}","string"]},{"Name":"IsForward","Docs":"","Typewords":["bool"]},{"Name":"ListAllowDomain","Docs":"","Typewords":["string"]},{"Name":"AcceptRejectsToMailbox","Docs":"","Typewords":["string"]},{"Name":"Mailbox","Docs":"","Typewords":["string"]},{"Name":"Comment","Docs":"","Typewords":["string"]},{"Name":"VerifiedDNSDomain","Docs":"","Typewords":["Domain"]},{"Name":"ListAllowDNSDomain","Docs":"","Typewords":["Domain"]}]},
"Domain": {"Name":"Domain","Docs":"","Fields":[{"Name":"ASCII","Docs":"","Typewords":["string"]},{"Name":"Unicode","Docs":"","Typewords":["string"]}]},
"SubjectPass": {"Name":"SubjectPass","Docs":"","Fields":[{"Name":"Period","Docs":"","Typewords":["int64"]}]},
Expand Down
2 changes: 1 addition & 1 deletion webadmin/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,7 @@ var api;
"Alias": { "Name": "Alias", "Docs": "", "Fields": [{ "Name": "Addresses", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "PostPublic", "Docs": "", "Typewords": ["bool"] }, { "Name": "ListMembers", "Docs": "", "Typewords": ["bool"] }, { "Name": "AllowMsgFrom", "Docs": "", "Typewords": ["bool"] }, { "Name": "LocalpartStr", "Docs": "", "Typewords": ["string"] }, { "Name": "Domain", "Docs": "", "Typewords": ["Domain"] }, { "Name": "ParsedAddresses", "Docs": "", "Typewords": ["[]", "AliasAddress"] }] },
"AliasAddress": { "Name": "AliasAddress", "Docs": "", "Fields": [{ "Name": "Address", "Docs": "", "Typewords": ["Address"] }, { "Name": "AccountName", "Docs": "", "Typewords": ["string"] }, { "Name": "Destination", "Docs": "", "Typewords": ["Destination"] }] },
"Address": { "Name": "Address", "Docs": "", "Fields": [{ "Name": "Localpart", "Docs": "", "Typewords": ["Localpart"] }, { "Name": "Domain", "Docs": "", "Typewords": ["Domain"] }] },
"Destination": { "Name": "Destination", "Docs": "", "Fields": [{ "Name": "Mailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "Rulesets", "Docs": "", "Typewords": ["[]", "Ruleset"] }, { "Name": "SMTPError", "Docs": "", "Typewords": ["string"] }, { "Name": "FullName", "Docs": "", "Typewords": ["string"] }] },
"Destination": { "Name": "Destination", "Docs": "", "Fields": [{ "Name": "Mailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "Rulesets", "Docs": "", "Typewords": ["[]", "Ruleset"] }, { "Name": "SMTPError", "Docs": "", "Typewords": ["string"] }, { "Name": "MessageAuthRequiredSMTPError", "Docs": "", "Typewords": ["string"] }, { "Name": "FullName", "Docs": "", "Typewords": ["string"] }] },
"Ruleset": { "Name": "Ruleset", "Docs": "", "Fields": [{ "Name": "SMTPMailFromRegexp", "Docs": "", "Typewords": ["string"] }, { "Name": "MsgFromRegexp", "Docs": "", "Typewords": ["string"] }, { "Name": "VerifiedDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "HeadersRegexp", "Docs": "", "Typewords": ["{}", "string"] }, { "Name": "IsForward", "Docs": "", "Typewords": ["bool"] }, { "Name": "ListAllowDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "AcceptRejectsToMailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "Mailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "Comment", "Docs": "", "Typewords": ["string"] }, { "Name": "VerifiedDNSDomain", "Docs": "", "Typewords": ["Domain"] }, { "Name": "ListAllowDNSDomain", "Docs": "", "Typewords": ["Domain"] }] },
"Account": { "Name": "Account", "Docs": "", "Fields": [{ "Name": "OutgoingWebhook", "Docs": "", "Typewords": ["nullable", "OutgoingWebhook"] }, { "Name": "IncomingWebhook", "Docs": "", "Typewords": ["nullable", "IncomingWebhook"] }, { "Name": "FromIDLoginAddresses", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "KeepRetiredMessagePeriod", "Docs": "", "Typewords": ["int64"] }, { "Name": "KeepRetiredWebhookPeriod", "Docs": "", "Typewords": ["int64"] }, { "Name": "LoginDisabled", "Docs": "", "Typewords": ["string"] }, { "Name": "Domain", "Docs": "", "Typewords": ["string"] }, { "Name": "Description", "Docs": "", "Typewords": ["string"] }, { "Name": "FullName", "Docs": "", "Typewords": ["string"] }, { "Name": "Destinations", "Docs": "", "Typewords": ["{}", "Destination"] }, { "Name": "SubjectPass", "Docs": "", "Typewords": ["SubjectPass"] }, { "Name": "QuotaMessageSize", "Docs": "", "Typewords": ["int64"] }, { "Name": "RejectsMailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "KeepRejects", "Docs": "", "Typewords": ["bool"] }, { "Name": "AutomaticJunkFlags", "Docs": "", "Typewords": ["AutomaticJunkFlags"] }, { "Name": "JunkFilter", "Docs": "", "Typewords": ["nullable", "JunkFilter"] }, { "Name": "MaxOutgoingMessagesPerDay", "Docs": "", "Typewords": ["int32"] }, { "Name": "MaxFirstTimeRecipientsPerDay", "Docs": "", "Typewords": ["int32"] }, { "Name": "NoFirstTimeSenderDelay", "Docs": "", "Typewords": ["bool"] }, { "Name": "NoCustomPassword", "Docs": "", "Typewords": ["bool"] }, { "Name": "Routes", "Docs": "", "Typewords": ["[]", "Route"] }, { "Name": "DNSDomain", "Docs": "", "Typewords": ["Domain"] }, { "Name": "Aliases", "Docs": "", "Typewords": ["[]", "AddressAlias"] }] },
"OutgoingWebhook": { "Name": "OutgoingWebhook", "Docs": "", "Fields": [{ "Name": "URL", "Docs": "", "Typewords": ["string"] }, { "Name": "Authorization", "Docs": "", "Typewords": ["string"] }, { "Name": "Events", "Docs": "", "Typewords": ["[]", "string"] }] },
Expand Down
7 changes: 7 additions & 0 deletions webadmin/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -3930,6 +3930,13 @@
"string"
]
},
{
"Name": "MessageAuthRequiredSMTPError",
"Docs": "",
"Typewords": [
"string"
]
},
{
"Name": "FullName",
"Docs": "",
Expand Down
Loading

0 comments on commit 6da5f8f

Please sign in to comment.