Skip to content

Commit 6da5f8f

Browse files
committed
add config option to an account destination to reject messages that don'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.
1 parent f33870b commit 6da5f8f

File tree

13 files changed

+108
-9
lines changed

13 files changed

+108
-9
lines changed

config/config.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -458,10 +458,11 @@ type JunkFilter struct {
458458
}
459459

460460
type Destination struct {
461-
Mailbox string `sconf:"optional" sconf-doc:"Mailbox to deliver to if none of Rulesets match. Default: Inbox."`
462-
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 [email protected]), delivering them to their own mailbox."`
463-
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."`
464-
FullName string `sconf:"optional" sconf-doc:"Full name to use in message From header when composing messages coming from this address with webmail."`
461+
Mailbox string `sconf:"optional" sconf-doc:"Mailbox to deliver to if none of Rulesets match. Default: Inbox."`
462+
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 [email protected]), delivering them to their own mailbox."`
463+
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."`
464+
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."`
465+
FullName string `sconf:"optional" sconf-doc:"Full name to use in message From header when composing messages coming from this address with webmail."`
465466

466467
DMARCReports bool `sconf:"-" json:"-"`
467468
HostTLSReports bool `sconf:"-" json:"-"`

config/doc.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1150,6 +1150,17 @@ See https://pkg.go.dev/github.com/mjl-/sconf for details.
11501150
# (optional)
11511151
SMTPError:
11521152
1153+
# If non-empty, an additional DMARC-like message authentication check is done for
1154+
# incoming messages, validating the domain in the From-header of the message.
1155+
# Messages without either an aligned SPF or aligned DKIM pass are rejected during
1156+
# the SMTP DATA command with a permanent error code followed by the message in
1157+
# this field. The domain in the message 'From' header is matched in relaxed or
1158+
# strict mode according to the domain's DMARC policy if present, or relaxed mode
1159+
# (organizational instead of exact domain match) otherwise. Useful for
1160+
# autoresponders that don't want to accept messages they don't want to send an
1161+
# automated reply to. (optional)
1162+
MessageAuthRequiredSMTPError:
1163+
11531164
# Full name to use in message From header when composing messages coming from this
11541165
# address with webmail. (optional)
11551166
FullName:

mox-/config.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1512,6 +1512,18 @@ func prepareDynamicConfig(ctx context.Context, log mlog.Log, dynamicPath string,
15121512
acc.Destinations[addrName] = dest
15131513
}
15141514

1515+
if dest.MessageAuthRequiredSMTPError != "" {
1516+
if len(dest.MessageAuthRequiredSMTPError) > 256 {
1517+
addDestErrorf("message authentication required smtp error must be smaller than 256 bytes")
1518+
}
1519+
for _, c := range dest.MessageAuthRequiredSMTPError {
1520+
if c < ' ' || c >= 0x7f {
1521+
addDestErrorf("message authentication required smtp error cannot contain contain control characters (including newlines) or non-ascii")
1522+
break
1523+
}
1524+
}
1525+
}
1526+
15151527
for i, rs := range dest.Rulesets {
15161528
addRulesetErrorf := func(format string, args ...any) {
15171529
addDestErrorf("ruleset %d: %s", i+1, fmt.Sprintf(format, args...))

smtpserver/analyze.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ const (
8484
reasonSubjectpassError = "subjectpass-error"
8585
reasonIPrev = "iprev" // No or mild junk reputation signals, and bad iprev.
8686
reasonHighRate = "high-rate" // Too many messages, not added to rejects.
87+
reasonMsgAuthRequired = "msg-auth-required"
8788
)
8889

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

400+
// We may have to reject messages that don't pass a relaxed aligned SPF and/or DKIM
401+
// check. Useful for services with autoresponders.
402+
if d.destination.MessageAuthRequiredSMTPError != "" && !d.m.MsgFromValidated {
403+
code := smtp.C550MailboxUnavail
404+
msg := d.destination.MessageAuthRequiredSMTPError
405+
if d.dmarcResult.Status == dmarc.StatusTemperror {
406+
code = smtp.C451LocalErr
407+
msg = "transient verification error: " + msg
408+
}
409+
addReasonText("message does not pass required aligned spf and/or dkim check required for destination")
410+
return reject(code, smtp.SePol7MultiAuthFails26, msg, nil, reasonMsgAuthRequired)
411+
}
412+
399413
// Determine if message is acceptable based on DMARC domain, DKIM identities, or
400414
// host-based reputation.
401415
var isjunk *bool

smtpserver/server_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2038,3 +2038,36 @@ func TestDestinationSMTPError(t *testing.T) {
20382038
ts.smtpErr(err, &smtpclient.Error{Permanent: true, Code: smtp.C550MailboxUnavail, Secode: smtp.SeAddr1UnknownDestMailbox1})
20392039
})
20402040
}
2041+
2042+
// TestDestinationMessageAuthRequiredSMTPError checks delivery to a destination
2043+
// with an MessageAuthRequiredSMTPError is accepted/rejected as configured.
2044+
func TestDestinationMessageAuthRequiredSMTPError(t *testing.T) {
2045+
resolver := dns.MockResolver{
2046+
A: map[string][]string{
2047+
"example.org.": {"127.0.0.10"}, // For mx check.
2048+
},
2049+
PTR: map[string][]string{
2050+
"127.0.0.10": {"example.org."},
2051+
},
2052+
TXT: map[string][]string{},
2053+
}
2054+
2055+
ts := newTestServer(t, filepath.FromSlash("../testdata/smtp/mox.conf"), resolver)
2056+
defer ts.close()
2057+
2058+
ts.run(func(client *smtpclient.Client) {
2059+
mailFrom := "[email protected]"
2060+
rcptTo := "[email protected]"
2061+
err := client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false, false)
2062+
ts.smtpErr(err, &smtpclient.Error{Permanent: true, Code: smtp.C550MailboxUnavail, Secode: smtp.SePol7MultiAuthFails26})
2063+
})
2064+
2065+
// Ensure SPF pass, message should now be accepted.
2066+
resolver.TXT["example.org."] = []string{"v=spf1 ip4:127.0.0.10 -all"}
2067+
ts.run(func(client *smtpclient.Client) {
2068+
mailFrom := "[email protected]"
2069+
rcptTo := "[email protected]"
2070+
err := client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false, false)
2071+
ts.smtpErr(err, nil)
2072+
})
2073+
}

testdata/smtp/domains.conf

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ Accounts:
2828
2929
3030
SMTPError: 550 no more messages
31+
32+
MessageAuthRequiredSMTPError: cannot authenticate domain in message-from header, ensure aligned spf/dkim pass
3133
3234
JunkFilter:
3335
Threshold: 0.9

webaccount/account.js

Lines changed: 4 additions & 2 deletions
Large diffs are not rendered by default.

webaccount/account.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1824,6 +1824,7 @@ const destination = async (name: string) => {
18241824
let defaultMailbox: HTMLInputElement
18251825
let fullName: HTMLInputElement
18261826
let smtpError: HTMLInputElement
1827+
let msgAuthRequiredSMTPError: HTMLInputElement
18271828
let saveButton: HTMLButtonElement
18281829

18291830
const addresses = [name, ...Object.keys(acc.Destinations || {}).filter(a => !a.startsWith('@') && a !== name)]
@@ -1851,6 +1852,12 @@ const destination = async (name: string) => {
18511852
smtpError=dom.input(attr.value(dest.SMTPError), attr.placeholder('421 or 550...')),
18521853
),
18531854
dom.br(),
1855+
dom.div(
1856+
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.")),
1857+
dom.br(),
1858+
msgAuthRequiredSMTPError=dom.input(attr.value(dest.MessageAuthRequiredSMTPError), attr.placeholder('messages must have aligned spf/dkim for domain authentication...')),
1859+
),
1860+
dom.br(),
18541861

18551862
dom.h2('Rulesets'),
18561863
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.'),
@@ -1917,6 +1924,7 @@ const destination = async (name: string) => {
19171924
}
19181925
}),
19191926
SMTPError: smtpError.value,
1927+
MessageAuthRequiredSMTPError: msgAuthRequiredSMTPError.value,
19201928
}
19211929
await check(saveButton, client.DestinationSave(name, dest, newDest))
19221930
window.location.reload() // todo: only refresh part of ui

webaccount/api.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -814,6 +814,13 @@
814814
"string"
815815
]
816816
},
817+
{
818+
"Name": "MessageAuthRequiredSMTPError",
819+
"Docs": "",
820+
"Typewords": [
821+
"string"
822+
]
823+
},
817824
{
818825
"Name": "FullName",
819826
"Docs": "",

webaccount/api.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export interface Destination {
4343
Mailbox: string
4444
Rulesets?: Ruleset[] | null
4545
SMTPError: string
46+
MessageAuthRequiredSMTPError: string
4647
FullName: string
4748
}
4849

@@ -298,7 +299,7 @@ export const types: TypenameMap = {
298299
"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"]}]},
299300
"OutgoingWebhook": {"Name":"OutgoingWebhook","Docs":"","Fields":[{"Name":"URL","Docs":"","Typewords":["string"]},{"Name":"Authorization","Docs":"","Typewords":["string"]},{"Name":"Events","Docs":"","Typewords":["[]","string"]}]},
300301
"IncomingWebhook": {"Name":"IncomingWebhook","Docs":"","Fields":[{"Name":"URL","Docs":"","Typewords":["string"]},{"Name":"Authorization","Docs":"","Typewords":["string"]}]},
301-
"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"]}]},
302+
"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"]}]},
302303
"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"]}]},
303304
"Domain": {"Name":"Domain","Docs":"","Fields":[{"Name":"ASCII","Docs":"","Typewords":["string"]},{"Name":"Unicode","Docs":"","Typewords":["string"]}]},
304305
"SubjectPass": {"Name":"SubjectPass","Docs":"","Fields":[{"Name":"Period","Docs":"","Typewords":["int64"]}]},

0 commit comments

Comments
 (0)