From 01deecb684676d4e14fe42e36794c7e35c3c9bd0 Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Mon, 25 Nov 2024 13:25:12 +0100 Subject: [PATCH 01/20] smtpserver: log an error message at debug level when we cannot parse a message for the smtputf8 check instead of not logging any message. this should make it easier to debug. based on delivery issue due to smtputf8 seen by wneessen. --- smtpserver/server.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/smtpserver/server.go b/smtpserver/server.go index 43ff2ca9b..dd87ec754 100644 --- a/smtpserver/server.go +++ b/smtpserver/server.go @@ -1853,6 +1853,9 @@ func (c *conn) cmdData(p *parser) { c.msgsmtputf8 = c.isSMTPUTF8Required(part) } } + if err != nil { + c.log.Debugx("parsing message for smtputf8 check", err) + } if c.smtputf8 != c.msgsmtputf8 { c.log.Debug("smtputf8 flag changed", slog.Bool("smtputf8", c.smtputf8), slog.Bool("msgsmtputf8", c.msgsmtputf8)) } From 636bb91df6d827187d358dd1198d56765a9ec222 Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Thu, 28 Nov 2024 16:06:20 +0100 Subject: [PATCH 02/20] webaccount: tweak text about opening apple mobileconfig profile files, it has gotten harder to use in ios18 since ios18, downloaded files don't go immediately to the settings (which is somewhat understandable given potential for abuse), but go to the Files app. opening them in the Files app then adds them to the settings where they can be installed. --- webaccount/account.js | 2 +- webaccount/account.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/webaccount/account.js b/webaccount/account.js index bf9f62d8b..26904c594 100644 --- a/webaccount/account.js +++ b/webaccount/account.js @@ -1728,7 +1728,7 @@ const destination = async (name) => { }; await check(saveButton, client.DestinationSave(name, dest, newDest)); window.location.reload(); // todo: only refresh part of ui - }), dom.br(), dom.br(), dom.br(), dom.p("Apple's mail applications don't do account autoconfiguration, and when adding an account it can choose defaults that don't work with modern email servers. Adding an account through a \"mobileconfig\" profile file can be more convenient: It contains the IMAP/SMTP settings such as host name, port, TLS, authentication mechanism and user name. This profile does not contain a login password. Opening the profile adds it under Profiles in System Preferences (macOS) or Settings (iOS), where you can install it. These profiles are not signed, so users will have to ignore the warnings about them being unsigned. ", dom.br(), dom.a(attr.href('https://autoconfig.' + domainName(acc.DNSDomain) + '/profile.mobileconfig?addresses=' + encodeURIComponent(addresses.join(',')) + '&name=' + encodeURIComponent(dest.FullName)), attr.download(''), 'Download .mobileconfig email account profile'), dom.br(), dom.a(attr.href('https://autoconfig.' + domainName(acc.DNSDomain) + '/profile.mobileconfig.qrcode.png?addresses=' + encodeURIComponent(addresses.join(',')) + '&name=' + encodeURIComponent(dest.FullName)), attr.download(''), 'Open QR-code with link to .mobileconfig profile'))); + }), dom.br(), dom.br(), dom.br(), dom.p("Apple's mail applications don't do account autoconfiguration, and when adding an account it can choose defaults that don't work with modern email servers. Adding an account through a \"mobileconfig\" profile file can be more convenient: It contains the IMAP/SMTP settings such as host name, port, TLS, authentication mechanism and user name. This profile does not contain a login password. Opening the profile in Safari adds it to the Files app on iOS. Opening the profile in the Files app then adds it under Profiles in System Preferences (macOS) or Settings (iOS), where you can install it. These profiles are not signed, so users will have to ignore the warnings about them being unsigned. ", dom.br(), dom.a(attr.href('https://autoconfig.' + domainName(acc.DNSDomain) + '/profile.mobileconfig?addresses=' + encodeURIComponent(addresses.join(',')) + '&name=' + encodeURIComponent(dest.FullName)), attr.download(''), 'Download .mobileconfig email account profile'), dom.br(), dom.a(attr.href('https://autoconfig.' + domainName(acc.DNSDomain) + '/profile.mobileconfig.qrcode.png?addresses=' + encodeURIComponent(addresses.join(',')) + '&name=' + encodeURIComponent(dest.FullName)), attr.download(''), 'Open QR-code with link to .mobileconfig profile'))); }; const init = async () => { let curhash; diff --git a/webaccount/account.ts b/webaccount/account.ts index c328d04a8..9555ee035 100644 --- a/webaccount/account.ts +++ b/webaccount/account.ts @@ -1642,7 +1642,7 @@ const destination = async (name: string) => { dom.br(), dom.br(), dom.br(), - dom.p("Apple's mail applications don't do account autoconfiguration, and when adding an account it can choose defaults that don't work with modern email servers. Adding an account through a \"mobileconfig\" profile file can be more convenient: It contains the IMAP/SMTP settings such as host name, port, TLS, authentication mechanism and user name. This profile does not contain a login password. Opening the profile adds it under Profiles in System Preferences (macOS) or Settings (iOS), where you can install it. These profiles are not signed, so users will have to ignore the warnings about them being unsigned. ", + dom.p("Apple's mail applications don't do account autoconfiguration, and when adding an account it can choose defaults that don't work with modern email servers. Adding an account through a \"mobileconfig\" profile file can be more convenient: It contains the IMAP/SMTP settings such as host name, port, TLS, authentication mechanism and user name. This profile does not contain a login password. Opening the profile in Safari adds it to the Files app on iOS. Opening the profile in the Files app then adds it under Profiles in System Preferences (macOS) or Settings (iOS), where you can install it. These profiles are not signed, so users will have to ignore the warnings about them being unsigned. ", dom.br(), dom.a(attr.href('https://autoconfig.'+domainName(acc.DNSDomain)+'/profile.mobileconfig?addresses='+encodeURIComponent(addresses.join(','))+'&name='+encodeURIComponent(dest.FullName)), attr.download(''), 'Download .mobileconfig email account profile'), dom.br(), From d7f057709f47bd53f19c2fb95043a56ca93748f5 Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Thu, 28 Nov 2024 16:28:05 +0100 Subject: [PATCH 03/20] include goversion used to compile mox in the mox version --- localserve.go | 2 +- main.go | 2 +- mox-/lastknown.go | 4 ++-- mox-/webappfile.go | 2 +- moxvar/version.go | 14 +++++++++++--- webaccount/account.ts | 1 - webadmin/admin.js | 2 +- webadmin/admin.ts | 2 -- webmail/webmail.js | 2 +- webmail/webmail.ts | 3 +-- 10 files changed, 19 insertions(+), 15 deletions(-) diff --git a/localserve.go b/localserve.go index d10ca5ce2..0a745453c 100644 --- a/localserve.go +++ b/localserve.go @@ -164,7 +164,7 @@ during those commands instead of during "data". loadLoglevel(log, fallbackLevel) - golog.Printf("mox, version %s, %s %s/%s", moxvar.Version, runtime.Version(), runtime.GOOS, runtime.GOARCH) + golog.Printf("mox, version %s %s/%s", moxvar.Version, runtime.GOOS, runtime.GOARCH) golog.Print("") golog.Printf("the default user is mox@localhost, with password moxmoxmox") golog.Printf("the default admin password is moxadmin") diff --git a/main.go b/main.go index 8fbca8ab7..ec2b662c0 100644 --- a/main.go +++ b/main.go @@ -2845,7 +2845,7 @@ func cmdVersion(c *cmd) { c.Usage() } fmt.Println(moxvar.Version) - fmt.Printf("%s %s/%s\n", runtime.Version(), runtime.GOOS, runtime.GOARCH) + fmt.Printf("%s/%s\n", runtime.GOOS, runtime.GOARCH) } func cmdWebapi(c *cmd) { diff --git a/mox-/lastknown.go b/mox-/lastknown.go index b1a0da9db..1cf9c3543 100644 --- a/mox-/lastknown.go +++ b/mox-/lastknown.go @@ -19,7 +19,7 @@ func StoreLastKnown(v updates.Version) error { // LastKnown returns the last known version that has been mentioned in an update // email, or the current application. func LastKnown() (current, lastknown updates.Version, mtime time.Time, rerr error) { - curv, curerr := updates.ParseVersion(moxvar.Version) + curv, curerr := updates.ParseVersion(moxvar.VersionBare) p := DataDirPath("lastknownversion") fi, _ := os.Stat(p) @@ -44,7 +44,7 @@ func LastKnown() (current, lastknown updates.Version, mtime time.Time, rerr erro } else if lasterr == nil { return curv, lastknown, mtime, nil } - if moxvar.Version == "(devel)" { + if strings.HasPrefix(moxvar.Version, "(devel)") { return curv, updates.Version{}, mtime, fmt.Errorf("development version") } return curv, updates.Version{}, mtime, fmt.Errorf("parsing version: %w", err) diff --git a/mox-/webappfile.go b/mox-/webappfile.go index f6bebc1e7..6eaeac085 100644 --- a/mox-/webappfile.go +++ b/mox-/webappfile.go @@ -125,7 +125,7 @@ func (a *WebappFile) Serve(ctx context.Context, log mlog.Log, w http.ResponseWri } var b bytes.Buffer b.Write(html[:index]) - fmt.Fprintf(&b, "") b.Write(html[index+len(script):]) diff --git a/moxvar/version.go b/moxvar/version.go index 0141b86e9..0b69cd9bb 100644 --- a/moxvar/version.go +++ b/moxvar/version.go @@ -2,16 +2,24 @@ package moxvar import ( + "runtime" "runtime/debug" ) // Version is set at runtime based on the Go module used to build. -var Version = "(devel)" +var Version string -// VersionBare does not add a "+modifications" or other suffix to the version. -var VersionBare = "(devel)" +// VersionBare does not add a "+modifications", goversion or other suffix to the version. +var VersionBare string func init() { + Version = "(devel)" + VersionBare = "(devel)" + + defer func() { + Version += "-" + runtime.Version() + }() + buildInfo, ok := debug.ReadBuildInfo() if !ok { return diff --git a/webaccount/account.ts b/webaccount/account.ts index 9555ee035..c50354805 100644 --- a/webaccount/account.ts +++ b/webaccount/account.ts @@ -3,7 +3,6 @@ // From HTML. declare let page: HTMLElement declare let moxversion: string -declare let moxgoversion: string declare let moxgoos: string declare let moxgoarch: string diff --git a/webadmin/admin.js b/webadmin/admin.js index 735d0fb54..b1e8e2c48 100644 --- a/webadmin/admin.js +++ b/webadmin/admin.js @@ -1782,7 +1782,7 @@ const crumbs = (...l) => { ]; }; const errmsg = (err) => '' + (err.message || '(no error message)'); -const footer = dom.div(style({ marginTop: '6ex', opacity: 0.75 }), link('https://www.xmox.nl', 'mox'), ' ', moxversion, ' ', moxgoversion, ' ', moxgoos, '/', moxgoarch, ', ', dom.a(attr.href('licenses.txt'), 'licenses')); +const footer = dom.div(style({ marginTop: '6ex', opacity: 0.75 }), link('https://www.xmox.nl', 'mox'), ' ', moxversion, ' ', moxgoos, '/', moxgoarch, ', ', dom.a(attr.href('licenses.txt'), 'licenses')); const age = (date, future, nowSecs) => { if (!nowSecs) { nowSecs = new Date().getTime() / 1000; diff --git a/webadmin/admin.ts b/webadmin/admin.ts index 4087efda4..d1b30d9cf 100644 --- a/webadmin/admin.ts +++ b/webadmin/admin.ts @@ -3,7 +3,6 @@ // From HTML. declare let page: HTMLElement declare let moxversion: string -declare let moxgoversion: string declare let moxgoos: string declare let moxgoarch: string @@ -195,7 +194,6 @@ const footer = dom.div( link('https://www.xmox.nl', 'mox'), ' ', moxversion, ' ', - moxgoversion, ' ', moxgoos, '/', moxgoarch, ', ', dom.a(attr.href('licenses.txt'), 'licenses') ) diff --git a/webmail/webmail.js b/webmail/webmail.js index a65e440ca..61048c046 100644 --- a/webmail/webmail.js +++ b/webmail/webmail.js @@ -2513,7 +2513,7 @@ const cmdHelp = async () => { return; } window.alert('"mailto:" protocol handler unregistered.'); - })), dom.div(style({ marginTop: '2ex' }), 'Mox is open source email server software, this is version ', moxversion, ', built with ', moxgoversion, ', see ', dom.a(attr.href('licenses.txt'), 'licenses'), '.', dom.br(), 'Feedback, including bug reports, is appreciated! ', link('https://github.com/mjl-/mox/issues/new'))))); + })), dom.div(style({ marginTop: '2ex' }), 'Mox is open source email server software, this is version ', moxversion, ', see ', dom.a(attr.href('licenses.txt'), 'licenses'), '.', dom.br(), 'Feedback, including bug reports, is appreciated! ', link('https://github.com/mjl-/mox/issues/new'))))); }; // Show tooltips for either the focused element, or otherwise for all elements // that aren't reachable with tabindex and aren't marked specially to prevent diff --git a/webmail/webmail.ts b/webmail/webmail.ts index af25bbced..4fefd5fbe 100644 --- a/webmail/webmail.ts +++ b/webmail/webmail.ts @@ -131,7 +131,6 @@ ensureCSS('.autosize::after', {content: 'attr(data-value)', marginRight: '1em', // From HTML. declare let page: HTMLElement declare let moxversion: string -declare let moxgoversion: string declare let moxgoos: string declare let moxgoarch: string @@ -1335,7 +1334,7 @@ const cmdHelp = async () => { window.alert('"mailto:" protocol handler unregistered.') }), ), - dom.div(style({marginTop: '2ex'}), 'Mox is open source email server software, this is version ', moxversion, ', built with ', moxgoversion, ', see ', dom.a(attr.href('licenses.txt'), 'licenses'), '.', dom.br(), 'Feedback, including bug reports, is appreciated! ', link('https://github.com/mjl-/mox/issues/new')), + dom.div(style({marginTop: '2ex'}), 'Mox is open source email server software, this is version ', moxversion, ', see ', dom.a(attr.href('licenses.txt'), 'licenses'), '.', dom.br(), 'Feedback, including bug reports, is appreciated! ', link('https://github.com/mjl-/mox/issues/new')), ), ), ) From bd693805fd346c6be3355b711f358e912020ee2f Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Thu, 28 Nov 2024 16:46:24 +0100 Subject: [PATCH 04/20] webmail: tweak color for label about encrypted/signed messages it wasn't very readable, probably since the change that introduced dark mode. --- webmail/lib.ts | 4 ++-- webmail/msg.js | 2 +- webmail/text.js | 2 +- webmail/webmail.js | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/webmail/lib.ts b/webmail/lib.ts index f463b3925..b05bd433d 100644 --- a/webmail/lib.ts +++ b/webmail/lib.ts @@ -420,8 +420,8 @@ const loadMsgheaderView = (msgheaderelem: HTMLElement, mi: api.MessageItem, more mi.Message.ReceivedTLSVersion === 1 ? dom.span(msgAttrStyle, css('msgAttrNoTLS', {borderBottom: '1.5px solid', borderBottomColor: styles.underlineRed}), 'Without TLS', attr.title('Message received (last hop) without TLS.')) : [], mi.Message.ReceivedTLSVersion > 1 && !mi.Message.ReceivedRequireTLS ? dom.span(msgAttrStyle, css('msgAttrTLS', {borderBottom: '1.5px solid', borderBottomColor: styles.underlineGreen}), 'With TLS', attr.title('Message received (last hop) with TLS.')) : [], mi.Message.ReceivedRequireTLS ? dom.span(css('msgAttrRequireTLS', {padding: '.1em .3em', fontSize: '.9em', backgroundColor: styles.successBackground, border: '1px solid', borderColor: styles.borderColor, borderRadius: '3px'}), 'With RequireTLS', attr.title('Transported with RequireTLS, ensuring TLS along the entire delivery path from sender to recipient, with TLS certificate verification through MTA-STS and/or DANE.')) : [], - mi.IsSigned ? dom.span(msgAttrStyle, css('msgAttrSigned', {backgroundColor: styles.emphasisBackground, color: styles.color, borderRadius: '.15em'}), 'Message has a signature') : [], - mi.IsEncrypted ? dom.span(msgAttrStyle, css('msgAttrEncrypted', {backgroundColor: styles.emphasisBackground, color: styles.color, borderRadius: '.15em'}), 'Message is encrypted') : [], + mi.IsSigned ? dom.span(msgAttrStyle, css('msgAttrSigned', {backgroundColor: styles.colorMild, color: styles.backgroundColorMild, borderRadius: '.15em'}), 'Message has a signature') : [], + mi.IsEncrypted ? dom.span(msgAttrStyle, css('msgAttrEncrypted', {backgroundColor: styles.colorMild, color: styles.backgroundColorMild, borderRadius: '.15em'}), 'Message is encrypted') : [], refineKeyword ? (mi.Message.Keywords || []).map(kw => dom.clickbutton(styleClasses.keyword, dom._class('keywordButton'), kw, async function click() { await refineKeyword(kw) diff --git a/webmail/msg.js b/webmail/msg.js index f93eea621..076e4e250 100644 --- a/webmail/msg.js +++ b/webmail/msg.js @@ -1384,7 +1384,7 @@ const loadMsgheaderView = (msgheaderelem, mi, moreHeaders, refineKeyword, allAdd const msgAttrStyle = css('msgAttr', { padding: '0px 0.15em', fontSize: '.9em' }); dom._kids(msgheaderelem, // todo: make addresses clickable, start search (keep current mailbox if any) - dom.tr(dom.td('From:', msgHeaderFieldStyle), dom.td(style({ width: '100%' }), dom.div(css('msgFromReceivedSpread', { display: 'flex', justifyContent: 'space-between' }), dom.div(join((msgenv.From || []).map(a => formatAddressValidated(a, mi.Message, !!msgenv.From && msgenv.From.length === 1)), () => ', ')), dom.div(attr.title('Received: ' + received.toString() + ';\nDate header in message: ' + (msgenv.Date ? msgenv.Date.toString() : '(missing/invalid)')), receivedlocal.toDateString() + ' ' + receivedlocal.toTimeString().split(' ')[0])))), (msgenv.ReplyTo || []).length === 0 ? [] : dom.tr(dom.td('Reply-To:', msgHeaderFieldStyle), dom.td(join((msgenv.ReplyTo || []).map(a => formatAddressElem(a)), () => ', '))), dom.tr(dom.td('To:', msgHeaderFieldStyle), dom.td(addressList(allAddrs, msgenv.To || []))), (msgenv.CC || []).length === 0 ? [] : dom.tr(dom.td('Cc:', msgHeaderFieldStyle), dom.td(addressList(allAddrs, msgenv.CC || []))), (msgenv.BCC || []).length === 0 ? [] : dom.tr(dom.td('Bcc:', msgHeaderFieldStyle), dom.td(addressList(allAddrs, msgenv.BCC || []))), dom.tr(dom.td('Subject:', msgHeaderFieldStyle), dom.td(dom.div(css('msgSubjectAttrsSpread', { display: 'flex', justifyContent: 'space-between' }), dom.div(msgenv.Subject || ''), dom.div(mi.Message.IsForward ? dom.span(msgAttrStyle, 'Forwarded', attr.title('Message came in from a forwarded address. Some message authentication policies, like DMARC, were not evaluated.')) : [], mi.Message.IsMailingList ? dom.span(msgAttrStyle, 'Mailing list', attr.title('Message was received from a mailing list. Some message authentication policies, like DMARC, were not evaluated.')) : [], mi.Message.ReceivedTLSVersion === 1 ? dom.span(msgAttrStyle, css('msgAttrNoTLS', { borderBottom: '1.5px solid', borderBottomColor: styles.underlineRed }), 'Without TLS', attr.title('Message received (last hop) without TLS.')) : [], mi.Message.ReceivedTLSVersion > 1 && !mi.Message.ReceivedRequireTLS ? dom.span(msgAttrStyle, css('msgAttrTLS', { borderBottom: '1.5px solid', borderBottomColor: styles.underlineGreen }), 'With TLS', attr.title('Message received (last hop) with TLS.')) : [], mi.Message.ReceivedRequireTLS ? dom.span(css('msgAttrRequireTLS', { padding: '.1em .3em', fontSize: '.9em', backgroundColor: styles.successBackground, border: '1px solid', borderColor: styles.borderColor, borderRadius: '3px' }), 'With RequireTLS', attr.title('Transported with RequireTLS, ensuring TLS along the entire delivery path from sender to recipient, with TLS certificate verification through MTA-STS and/or DANE.')) : [], mi.IsSigned ? dom.span(msgAttrStyle, css('msgAttrSigned', { backgroundColor: styles.emphasisBackground, color: styles.color, borderRadius: '.15em' }), 'Message has a signature') : [], mi.IsEncrypted ? dom.span(msgAttrStyle, css('msgAttrEncrypted', { backgroundColor: styles.emphasisBackground, color: styles.color, borderRadius: '.15em' }), 'Message is encrypted') : [], refineKeyword ? (mi.Message.Keywords || []).map(kw => dom.clickbutton(styleClasses.keyword, dom._class('keywordButton'), kw, async function click() { + dom.tr(dom.td('From:', msgHeaderFieldStyle), dom.td(style({ width: '100%' }), dom.div(css('msgFromReceivedSpread', { display: 'flex', justifyContent: 'space-between' }), dom.div(join((msgenv.From || []).map(a => formatAddressValidated(a, mi.Message, !!msgenv.From && msgenv.From.length === 1)), () => ', ')), dom.div(attr.title('Received: ' + received.toString() + ';\nDate header in message: ' + (msgenv.Date ? msgenv.Date.toString() : '(missing/invalid)')), receivedlocal.toDateString() + ' ' + receivedlocal.toTimeString().split(' ')[0])))), (msgenv.ReplyTo || []).length === 0 ? [] : dom.tr(dom.td('Reply-To:', msgHeaderFieldStyle), dom.td(join((msgenv.ReplyTo || []).map(a => formatAddressElem(a)), () => ', '))), dom.tr(dom.td('To:', msgHeaderFieldStyle), dom.td(addressList(allAddrs, msgenv.To || []))), (msgenv.CC || []).length === 0 ? [] : dom.tr(dom.td('Cc:', msgHeaderFieldStyle), dom.td(addressList(allAddrs, msgenv.CC || []))), (msgenv.BCC || []).length === 0 ? [] : dom.tr(dom.td('Bcc:', msgHeaderFieldStyle), dom.td(addressList(allAddrs, msgenv.BCC || []))), dom.tr(dom.td('Subject:', msgHeaderFieldStyle), dom.td(dom.div(css('msgSubjectAttrsSpread', { display: 'flex', justifyContent: 'space-between' }), dom.div(msgenv.Subject || ''), dom.div(mi.Message.IsForward ? dom.span(msgAttrStyle, 'Forwarded', attr.title('Message came in from a forwarded address. Some message authentication policies, like DMARC, were not evaluated.')) : [], mi.Message.IsMailingList ? dom.span(msgAttrStyle, 'Mailing list', attr.title('Message was received from a mailing list. Some message authentication policies, like DMARC, were not evaluated.')) : [], mi.Message.ReceivedTLSVersion === 1 ? dom.span(msgAttrStyle, css('msgAttrNoTLS', { borderBottom: '1.5px solid', borderBottomColor: styles.underlineRed }), 'Without TLS', attr.title('Message received (last hop) without TLS.')) : [], mi.Message.ReceivedTLSVersion > 1 && !mi.Message.ReceivedRequireTLS ? dom.span(msgAttrStyle, css('msgAttrTLS', { borderBottom: '1.5px solid', borderBottomColor: styles.underlineGreen }), 'With TLS', attr.title('Message received (last hop) with TLS.')) : [], mi.Message.ReceivedRequireTLS ? dom.span(css('msgAttrRequireTLS', { padding: '.1em .3em', fontSize: '.9em', backgroundColor: styles.successBackground, border: '1px solid', borderColor: styles.borderColor, borderRadius: '3px' }), 'With RequireTLS', attr.title('Transported with RequireTLS, ensuring TLS along the entire delivery path from sender to recipient, with TLS certificate verification through MTA-STS and/or DANE.')) : [], mi.IsSigned ? dom.span(msgAttrStyle, css('msgAttrSigned', { backgroundColor: styles.colorMild, color: styles.backgroundColorMild, borderRadius: '.15em' }), 'Message has a signature') : [], mi.IsEncrypted ? dom.span(msgAttrStyle, css('msgAttrEncrypted', { backgroundColor: styles.colorMild, color: styles.backgroundColorMild, borderRadius: '.15em' }), 'Message is encrypted') : [], refineKeyword ? (mi.Message.Keywords || []).map(kw => dom.clickbutton(styleClasses.keyword, dom._class('keywordButton'), kw, async function click() { await refineKeyword(kw); })) : [])))), moreHeaders.map(k => dom.tr(dom.td(k + ':', msgHeaderFieldStyle), dom.td()))); }; diff --git a/webmail/text.js b/webmail/text.js index 7da4d37e8..73496da68 100644 --- a/webmail/text.js +++ b/webmail/text.js @@ -1384,7 +1384,7 @@ const loadMsgheaderView = (msgheaderelem, mi, moreHeaders, refineKeyword, allAdd const msgAttrStyle = css('msgAttr', { padding: '0px 0.15em', fontSize: '.9em' }); dom._kids(msgheaderelem, // todo: make addresses clickable, start search (keep current mailbox if any) - dom.tr(dom.td('From:', msgHeaderFieldStyle), dom.td(style({ width: '100%' }), dom.div(css('msgFromReceivedSpread', { display: 'flex', justifyContent: 'space-between' }), dom.div(join((msgenv.From || []).map(a => formatAddressValidated(a, mi.Message, !!msgenv.From && msgenv.From.length === 1)), () => ', ')), dom.div(attr.title('Received: ' + received.toString() + ';\nDate header in message: ' + (msgenv.Date ? msgenv.Date.toString() : '(missing/invalid)')), receivedlocal.toDateString() + ' ' + receivedlocal.toTimeString().split(' ')[0])))), (msgenv.ReplyTo || []).length === 0 ? [] : dom.tr(dom.td('Reply-To:', msgHeaderFieldStyle), dom.td(join((msgenv.ReplyTo || []).map(a => formatAddressElem(a)), () => ', '))), dom.tr(dom.td('To:', msgHeaderFieldStyle), dom.td(addressList(allAddrs, msgenv.To || []))), (msgenv.CC || []).length === 0 ? [] : dom.tr(dom.td('Cc:', msgHeaderFieldStyle), dom.td(addressList(allAddrs, msgenv.CC || []))), (msgenv.BCC || []).length === 0 ? [] : dom.tr(dom.td('Bcc:', msgHeaderFieldStyle), dom.td(addressList(allAddrs, msgenv.BCC || []))), dom.tr(dom.td('Subject:', msgHeaderFieldStyle), dom.td(dom.div(css('msgSubjectAttrsSpread', { display: 'flex', justifyContent: 'space-between' }), dom.div(msgenv.Subject || ''), dom.div(mi.Message.IsForward ? dom.span(msgAttrStyle, 'Forwarded', attr.title('Message came in from a forwarded address. Some message authentication policies, like DMARC, were not evaluated.')) : [], mi.Message.IsMailingList ? dom.span(msgAttrStyle, 'Mailing list', attr.title('Message was received from a mailing list. Some message authentication policies, like DMARC, were not evaluated.')) : [], mi.Message.ReceivedTLSVersion === 1 ? dom.span(msgAttrStyle, css('msgAttrNoTLS', { borderBottom: '1.5px solid', borderBottomColor: styles.underlineRed }), 'Without TLS', attr.title('Message received (last hop) without TLS.')) : [], mi.Message.ReceivedTLSVersion > 1 && !mi.Message.ReceivedRequireTLS ? dom.span(msgAttrStyle, css('msgAttrTLS', { borderBottom: '1.5px solid', borderBottomColor: styles.underlineGreen }), 'With TLS', attr.title('Message received (last hop) with TLS.')) : [], mi.Message.ReceivedRequireTLS ? dom.span(css('msgAttrRequireTLS', { padding: '.1em .3em', fontSize: '.9em', backgroundColor: styles.successBackground, border: '1px solid', borderColor: styles.borderColor, borderRadius: '3px' }), 'With RequireTLS', attr.title('Transported with RequireTLS, ensuring TLS along the entire delivery path from sender to recipient, with TLS certificate verification through MTA-STS and/or DANE.')) : [], mi.IsSigned ? dom.span(msgAttrStyle, css('msgAttrSigned', { backgroundColor: styles.emphasisBackground, color: styles.color, borderRadius: '.15em' }), 'Message has a signature') : [], mi.IsEncrypted ? dom.span(msgAttrStyle, css('msgAttrEncrypted', { backgroundColor: styles.emphasisBackground, color: styles.color, borderRadius: '.15em' }), 'Message is encrypted') : [], refineKeyword ? (mi.Message.Keywords || []).map(kw => dom.clickbutton(styleClasses.keyword, dom._class('keywordButton'), kw, async function click() { + dom.tr(dom.td('From:', msgHeaderFieldStyle), dom.td(style({ width: '100%' }), dom.div(css('msgFromReceivedSpread', { display: 'flex', justifyContent: 'space-between' }), dom.div(join((msgenv.From || []).map(a => formatAddressValidated(a, mi.Message, !!msgenv.From && msgenv.From.length === 1)), () => ', ')), dom.div(attr.title('Received: ' + received.toString() + ';\nDate header in message: ' + (msgenv.Date ? msgenv.Date.toString() : '(missing/invalid)')), receivedlocal.toDateString() + ' ' + receivedlocal.toTimeString().split(' ')[0])))), (msgenv.ReplyTo || []).length === 0 ? [] : dom.tr(dom.td('Reply-To:', msgHeaderFieldStyle), dom.td(join((msgenv.ReplyTo || []).map(a => formatAddressElem(a)), () => ', '))), dom.tr(dom.td('To:', msgHeaderFieldStyle), dom.td(addressList(allAddrs, msgenv.To || []))), (msgenv.CC || []).length === 0 ? [] : dom.tr(dom.td('Cc:', msgHeaderFieldStyle), dom.td(addressList(allAddrs, msgenv.CC || []))), (msgenv.BCC || []).length === 0 ? [] : dom.tr(dom.td('Bcc:', msgHeaderFieldStyle), dom.td(addressList(allAddrs, msgenv.BCC || []))), dom.tr(dom.td('Subject:', msgHeaderFieldStyle), dom.td(dom.div(css('msgSubjectAttrsSpread', { display: 'flex', justifyContent: 'space-between' }), dom.div(msgenv.Subject || ''), dom.div(mi.Message.IsForward ? dom.span(msgAttrStyle, 'Forwarded', attr.title('Message came in from a forwarded address. Some message authentication policies, like DMARC, were not evaluated.')) : [], mi.Message.IsMailingList ? dom.span(msgAttrStyle, 'Mailing list', attr.title('Message was received from a mailing list. Some message authentication policies, like DMARC, were not evaluated.')) : [], mi.Message.ReceivedTLSVersion === 1 ? dom.span(msgAttrStyle, css('msgAttrNoTLS', { borderBottom: '1.5px solid', borderBottomColor: styles.underlineRed }), 'Without TLS', attr.title('Message received (last hop) without TLS.')) : [], mi.Message.ReceivedTLSVersion > 1 && !mi.Message.ReceivedRequireTLS ? dom.span(msgAttrStyle, css('msgAttrTLS', { borderBottom: '1.5px solid', borderBottomColor: styles.underlineGreen }), 'With TLS', attr.title('Message received (last hop) with TLS.')) : [], mi.Message.ReceivedRequireTLS ? dom.span(css('msgAttrRequireTLS', { padding: '.1em .3em', fontSize: '.9em', backgroundColor: styles.successBackground, border: '1px solid', borderColor: styles.borderColor, borderRadius: '3px' }), 'With RequireTLS', attr.title('Transported with RequireTLS, ensuring TLS along the entire delivery path from sender to recipient, with TLS certificate verification through MTA-STS and/or DANE.')) : [], mi.IsSigned ? dom.span(msgAttrStyle, css('msgAttrSigned', { backgroundColor: styles.colorMild, color: styles.backgroundColorMild, borderRadius: '.15em' }), 'Message has a signature') : [], mi.IsEncrypted ? dom.span(msgAttrStyle, css('msgAttrEncrypted', { backgroundColor: styles.colorMild, color: styles.backgroundColorMild, borderRadius: '.15em' }), 'Message is encrypted') : [], refineKeyword ? (mi.Message.Keywords || []).map(kw => dom.clickbutton(styleClasses.keyword, dom._class('keywordButton'), kw, async function click() { await refineKeyword(kw); })) : [])))), moreHeaders.map(k => dom.tr(dom.td(k + ':', msgHeaderFieldStyle), dom.td()))); }; diff --git a/webmail/webmail.js b/webmail/webmail.js index 61048c046..0c71cde72 100644 --- a/webmail/webmail.js +++ b/webmail/webmail.js @@ -1384,7 +1384,7 @@ const loadMsgheaderView = (msgheaderelem, mi, moreHeaders, refineKeyword, allAdd const msgAttrStyle = css('msgAttr', { padding: '0px 0.15em', fontSize: '.9em' }); dom._kids(msgheaderelem, // todo: make addresses clickable, start search (keep current mailbox if any) - dom.tr(dom.td('From:', msgHeaderFieldStyle), dom.td(style({ width: '100%' }), dom.div(css('msgFromReceivedSpread', { display: 'flex', justifyContent: 'space-between' }), dom.div(join((msgenv.From || []).map(a => formatAddressValidated(a, mi.Message, !!msgenv.From && msgenv.From.length === 1)), () => ', ')), dom.div(attr.title('Received: ' + received.toString() + ';\nDate header in message: ' + (msgenv.Date ? msgenv.Date.toString() : '(missing/invalid)')), receivedlocal.toDateString() + ' ' + receivedlocal.toTimeString().split(' ')[0])))), (msgenv.ReplyTo || []).length === 0 ? [] : dom.tr(dom.td('Reply-To:', msgHeaderFieldStyle), dom.td(join((msgenv.ReplyTo || []).map(a => formatAddressElem(a)), () => ', '))), dom.tr(dom.td('To:', msgHeaderFieldStyle), dom.td(addressList(allAddrs, msgenv.To || []))), (msgenv.CC || []).length === 0 ? [] : dom.tr(dom.td('Cc:', msgHeaderFieldStyle), dom.td(addressList(allAddrs, msgenv.CC || []))), (msgenv.BCC || []).length === 0 ? [] : dom.tr(dom.td('Bcc:', msgHeaderFieldStyle), dom.td(addressList(allAddrs, msgenv.BCC || []))), dom.tr(dom.td('Subject:', msgHeaderFieldStyle), dom.td(dom.div(css('msgSubjectAttrsSpread', { display: 'flex', justifyContent: 'space-between' }), dom.div(msgenv.Subject || ''), dom.div(mi.Message.IsForward ? dom.span(msgAttrStyle, 'Forwarded', attr.title('Message came in from a forwarded address. Some message authentication policies, like DMARC, were not evaluated.')) : [], mi.Message.IsMailingList ? dom.span(msgAttrStyle, 'Mailing list', attr.title('Message was received from a mailing list. Some message authentication policies, like DMARC, were not evaluated.')) : [], mi.Message.ReceivedTLSVersion === 1 ? dom.span(msgAttrStyle, css('msgAttrNoTLS', { borderBottom: '1.5px solid', borderBottomColor: styles.underlineRed }), 'Without TLS', attr.title('Message received (last hop) without TLS.')) : [], mi.Message.ReceivedTLSVersion > 1 && !mi.Message.ReceivedRequireTLS ? dom.span(msgAttrStyle, css('msgAttrTLS', { borderBottom: '1.5px solid', borderBottomColor: styles.underlineGreen }), 'With TLS', attr.title('Message received (last hop) with TLS.')) : [], mi.Message.ReceivedRequireTLS ? dom.span(css('msgAttrRequireTLS', { padding: '.1em .3em', fontSize: '.9em', backgroundColor: styles.successBackground, border: '1px solid', borderColor: styles.borderColor, borderRadius: '3px' }), 'With RequireTLS', attr.title('Transported with RequireTLS, ensuring TLS along the entire delivery path from sender to recipient, with TLS certificate verification through MTA-STS and/or DANE.')) : [], mi.IsSigned ? dom.span(msgAttrStyle, css('msgAttrSigned', { backgroundColor: styles.emphasisBackground, color: styles.color, borderRadius: '.15em' }), 'Message has a signature') : [], mi.IsEncrypted ? dom.span(msgAttrStyle, css('msgAttrEncrypted', { backgroundColor: styles.emphasisBackground, color: styles.color, borderRadius: '.15em' }), 'Message is encrypted') : [], refineKeyword ? (mi.Message.Keywords || []).map(kw => dom.clickbutton(styleClasses.keyword, dom._class('keywordButton'), kw, async function click() { + dom.tr(dom.td('From:', msgHeaderFieldStyle), dom.td(style({ width: '100%' }), dom.div(css('msgFromReceivedSpread', { display: 'flex', justifyContent: 'space-between' }), dom.div(join((msgenv.From || []).map(a => formatAddressValidated(a, mi.Message, !!msgenv.From && msgenv.From.length === 1)), () => ', ')), dom.div(attr.title('Received: ' + received.toString() + ';\nDate header in message: ' + (msgenv.Date ? msgenv.Date.toString() : '(missing/invalid)')), receivedlocal.toDateString() + ' ' + receivedlocal.toTimeString().split(' ')[0])))), (msgenv.ReplyTo || []).length === 0 ? [] : dom.tr(dom.td('Reply-To:', msgHeaderFieldStyle), dom.td(join((msgenv.ReplyTo || []).map(a => formatAddressElem(a)), () => ', '))), dom.tr(dom.td('To:', msgHeaderFieldStyle), dom.td(addressList(allAddrs, msgenv.To || []))), (msgenv.CC || []).length === 0 ? [] : dom.tr(dom.td('Cc:', msgHeaderFieldStyle), dom.td(addressList(allAddrs, msgenv.CC || []))), (msgenv.BCC || []).length === 0 ? [] : dom.tr(dom.td('Bcc:', msgHeaderFieldStyle), dom.td(addressList(allAddrs, msgenv.BCC || []))), dom.tr(dom.td('Subject:', msgHeaderFieldStyle), dom.td(dom.div(css('msgSubjectAttrsSpread', { display: 'flex', justifyContent: 'space-between' }), dom.div(msgenv.Subject || ''), dom.div(mi.Message.IsForward ? dom.span(msgAttrStyle, 'Forwarded', attr.title('Message came in from a forwarded address. Some message authentication policies, like DMARC, were not evaluated.')) : [], mi.Message.IsMailingList ? dom.span(msgAttrStyle, 'Mailing list', attr.title('Message was received from a mailing list. Some message authentication policies, like DMARC, were not evaluated.')) : [], mi.Message.ReceivedTLSVersion === 1 ? dom.span(msgAttrStyle, css('msgAttrNoTLS', { borderBottom: '1.5px solid', borderBottomColor: styles.underlineRed }), 'Without TLS', attr.title('Message received (last hop) without TLS.')) : [], mi.Message.ReceivedTLSVersion > 1 && !mi.Message.ReceivedRequireTLS ? dom.span(msgAttrStyle, css('msgAttrTLS', { borderBottom: '1.5px solid', borderBottomColor: styles.underlineGreen }), 'With TLS', attr.title('Message received (last hop) with TLS.')) : [], mi.Message.ReceivedRequireTLS ? dom.span(css('msgAttrRequireTLS', { padding: '.1em .3em', fontSize: '.9em', backgroundColor: styles.successBackground, border: '1px solid', borderColor: styles.borderColor, borderRadius: '3px' }), 'With RequireTLS', attr.title('Transported with RequireTLS, ensuring TLS along the entire delivery path from sender to recipient, with TLS certificate verification through MTA-STS and/or DANE.')) : [], mi.IsSigned ? dom.span(msgAttrStyle, css('msgAttrSigned', { backgroundColor: styles.colorMild, color: styles.backgroundColorMild, borderRadius: '.15em' }), 'Message has a signature') : [], mi.IsEncrypted ? dom.span(msgAttrStyle, css('msgAttrEncrypted', { backgroundColor: styles.colorMild, color: styles.backgroundColorMild, borderRadius: '.15em' }), 'Message is encrypted') : [], refineKeyword ? (mi.Message.Keywords || []).map(kw => dom.clickbutton(styleClasses.keyword, dom._class('keywordButton'), kw, async function click() { await refineKeyword(kw); })) : [])))), moreHeaders.map(k => dom.tr(dom.td(k + ':', msgHeaderFieldStyle), dom.td()))); }; From ee48cf0dfd46a889ecf0a6dbf579912b9f95875d Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Thu, 28 Nov 2024 17:22:01 +0100 Subject: [PATCH 05/20] webmail: fix using the compose window/popup after saving a draft message failed we kept the "save draft" promise, and would wait for it again for other operations (eg close, save again, send), which wouldn't make progress. can easily be reproduced by saving a message with a control character in an address or the subject. saving the draft will fail. for issue #256 by ally9335, thanks for reporting --- webmail/webmail.js | 17 +++++++++++------ webmail/webmail.ts | 16 ++++++++++------ 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/webmail/webmail.js b/webmail/webmail.js index 0c71cde72..a7c00a54a 100644 --- a/webmail/webmail.js +++ b/webmail/webmail.js @@ -2581,7 +2581,7 @@ const compose = (opts, listMailboxes) => { let draftSaveTimer = 0; let draftSavePromise = Promise.resolve(0); let draftLastText = opts.body; - const draftCancelSave = () => { + const draftCancelSaveTimer = () => { if (draftSaveTimer) { window.clearTimeout(draftSaveTimer); draftSaveTimer = 0; @@ -2598,7 +2598,7 @@ const compose = (opts, listMailboxes) => { }, 60 * 1000); }; const draftSave = async () => { - draftCancelSave(); + draftCancelSaveTimer(); let replyTo = ''; if (replytoViews && replytoViews.length === 1 && replytoViews[0].input.value) { replyTo = replytoViews[0].input.value; @@ -2619,7 +2619,12 @@ const compose = (opts, listMailboxes) => { throw new Error('no designated drafts mailbox'); } draftSavePromise = client.MessageCompose(cm, mbdrafts.ID); - draftMessageID = await draftSavePromise; + try { + draftMessageID = await draftSavePromise; + } + finally { + draftSavePromise = Promise.resolve(0); + } draftLastText = cm.TextBody; }; // todo future: on visibilitychange with visibilityState "hidden", use navigator.sendBeacon to save latest modified draft message? @@ -2630,7 +2635,7 @@ const compose = (opts, listMailboxes) => { // triggered. But we still have the beforeunload handler that checks for // unsavedChanges to protect the user in such cases. const cmdClose = async () => { - draftCancelSave(); + draftCancelSaveTimer(); await draftSavePromise; if (unsavedChanges()) { const action = await new Promise((resolve) => { @@ -2664,12 +2669,12 @@ const compose = (opts, listMailboxes) => { composeView = null; }; const cmdSave = async () => { - draftCancelSave(); + draftCancelSaveTimer(); await draftSavePromise; await withStatus('Saving draft', draftSave()); }; const submit = async (archive) => { - draftCancelSave(); + draftCancelSaveTimer(); await draftSavePromise; const files = await new Promise((resolve, reject) => { const l = []; diff --git a/webmail/webmail.ts b/webmail/webmail.ts index 4fefd5fbe..b15d4ebc5 100644 --- a/webmail/webmail.ts +++ b/webmail/webmail.ts @@ -1464,7 +1464,7 @@ const compose = (opts: ComposeOptions, listMailboxes: listMailboxes) => { let draftSavePromise = Promise.resolve(0) let draftLastText = opts.body - const draftCancelSave = () => { + const draftCancelSaveTimer = () => { if (draftSaveTimer) { window.clearTimeout(draftSaveTimer) draftSaveTimer = 0 @@ -1483,7 +1483,7 @@ const compose = (opts: ComposeOptions, listMailboxes: listMailboxes) => { } const draftSave = async () => { - draftCancelSave() + draftCancelSaveTimer() let replyTo = '' if (replytoViews && replytoViews.length === 1 && replytoViews[0].input.value) { replyTo = replytoViews[0].input.value @@ -1504,7 +1504,11 @@ const compose = (opts: ComposeOptions, listMailboxes: listMailboxes) => { throw new Error('no designated drafts mailbox') } draftSavePromise = client.MessageCompose(cm, mbdrafts.ID) - draftMessageID = await draftSavePromise + try { + draftMessageID = await draftSavePromise + } finally { + draftSavePromise = Promise.resolve(0) + } draftLastText = cm.TextBody } @@ -1518,7 +1522,7 @@ const compose = (opts: ComposeOptions, listMailboxes: listMailboxes) => { // triggered. But we still have the beforeunload handler that checks for // unsavedChanges to protect the user in such cases. const cmdClose = async () => { - draftCancelSave() + draftCancelSaveTimer() await draftSavePromise if (unsavedChanges()) { const action = await new Promise((resolve) => { @@ -1560,13 +1564,13 @@ const compose = (opts: ComposeOptions, listMailboxes: listMailboxes) => { } const cmdSave = async () => { - draftCancelSave() + draftCancelSaveTimer() await draftSavePromise await withStatus('Saving draft', draftSave()) } const submit = async (archive: boolean) => { - draftCancelSave() + draftCancelSaveTimer() await draftSavePromise const files = await new Promise((resolve, reject) => { From 1f604c6a3db41edb248ec914c44f78aaf8731cf6 Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Thu, 28 Nov 2024 18:24:03 +0100 Subject: [PATCH 06/20] webmail: when marking message as unread, also clear its (non)junk flags --- webmail/webmail.js | 6 +++--- webmail/webmail.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/webmail/webmail.js b/webmail/webmail.js index a7c00a54a..c1c6d9737 100644 --- a/webmail/webmail.js +++ b/webmail/webmail.js @@ -2435,7 +2435,7 @@ const cmdHelp = async () => { ['q', 'move to junk folder'], ['Q', 'mark not junk'], ['a', 'move to archive folder'], - ['M', 'mark unread'], + ['M', 'mark unread and clear (non)junk flags'], ['m', 'mark read'], ['u', 'to next unread message'], ['p', 'to root of thread or previous thread'], @@ -4110,7 +4110,7 @@ const newMsgView = (miv, msglistView, listMailboxes, possibleLabels, messageLoad if (!miv.messageitem.Message.Junk && !miv.messageitem.Message.Notjunk) { window.setTimeout(async () => { const mailboxIsReject = () => !!listMailboxes().find(mb => mb.ID === miv.messageitem.Message.MailboxID && mb.Name === rejectsMailbox); - if (!miv.messageitem.Message.Junk && !miv.messageitem.Message.Notjunk && miv.messageitem.Message.ID === msglistView.activeMessageID() && !mailboxIsReject()) { + if (!miv.messageitem.Message.Junk && !miv.messageitem.Message.Notjunk && miv.messageitem.Message.Seen && miv.messageitem.Message.ID === msglistView.activeMessageID() && !mailboxIsReject()) { await withStatus('Marking current message as not junk', client.FlagsAdd([miv.messageitem.Message.ID], ['$notjunk'])); } }, 5 * 1000); @@ -4191,7 +4191,7 @@ const newMsglistView = (msgElem, activeMailbox, listMailboxes, setLocationHash, }; const cmdMarkNotJunk = async () => { await withStatus('Marking as not junk', client.FlagsAdd(mlv.selected().map(miv => miv.messageitem.Message.ID), ['$notjunk'])); }; const cmdMarkRead = async () => { await withStatus('Marking as read', client.FlagsAdd(mlv.selected().map(miv => miv.messageitem.Message.ID), ['\\seen'])); }; - const cmdMarkUnread = async () => { await withStatus('Marking as not read', client.FlagsClear(mlv.selected().map(miv => miv.messageitem.Message.ID), ['\\seen'])); }; + const cmdMarkUnread = async () => { await withStatus('Marking as not read', client.FlagsClear(mlv.selected().map(miv => miv.messageitem.Message.ID), ['\\seen', '$junk', '$notjunk'])); }; const cmdMute = async () => { const l = mlv.selected(); await withStatus('Muting thread', client.ThreadMute(l.map(miv => miv.messageitem.Message.ID), true)); diff --git a/webmail/webmail.ts b/webmail/webmail.ts index b15d4ebc5..525acd554 100644 --- a/webmail/webmail.ts +++ b/webmail/webmail.ts @@ -1225,7 +1225,7 @@ const cmdHelp = async () => { ['q', 'move to junk folder'], ['Q', 'mark not junk'], ['a', 'move to archive folder'], - ['M', 'mark unread'], + ['M', 'mark unread and clear (non)junk flags'], ['m', 'mark read'], ['u', 'to next unread message'], ['p', 'to root of thread or previous thread'], @@ -3612,7 +3612,7 @@ const newMsgView = (miv: MsgitemView, msglistView: MsglistView, listMailboxes: l if (!miv.messageitem.Message.Junk && !miv.messageitem.Message.Notjunk) { window.setTimeout(async () => { const mailboxIsReject = () => !!listMailboxes().find(mb => mb.ID === miv.messageitem.Message.MailboxID && mb.Name === rejectsMailbox) - if (!miv.messageitem.Message.Junk && !miv.messageitem.Message.Notjunk && miv.messageitem.Message.ID === msglistView.activeMessageID() && !mailboxIsReject()) { + if (!miv.messageitem.Message.Junk && !miv.messageitem.Message.Notjunk && miv.messageitem.Message.Seen && miv.messageitem.Message.ID === msglistView.activeMessageID() && !mailboxIsReject()) { await withStatus('Marking current message as not junk', client.FlagsAdd([miv.messageitem.Message.ID], ['$notjunk'])) } }, 5*1000) @@ -3762,7 +3762,7 @@ const newMsglistView = (msgElem: HTMLElement, activeMailbox: () => api.Mailbox | } const cmdMarkNotJunk = async () => { await withStatus('Marking as not junk', client.FlagsAdd(mlv.selected().map(miv => miv.messageitem.Message.ID), ['$notjunk'])) } const cmdMarkRead = async () => { await withStatus('Marking as read', client.FlagsAdd(mlv.selected().map(miv => miv.messageitem.Message.ID), ['\\seen'])) } - const cmdMarkUnread = async () => { await withStatus('Marking as not read', client.FlagsClear(mlv.selected().map(miv => miv.messageitem.Message.ID), ['\\seen'])) } + const cmdMarkUnread = async () => { await withStatus('Marking as not read', client.FlagsClear(mlv.selected().map(miv => miv.messageitem.Message.ID), ['\\seen', '$junk', '$notjunk'])) } const cmdMute = async () => { const l = mlv.selected() await withStatus('Muting thread', client.ThreadMute(l.map(miv => miv.messageitem.Message.ID), true)) From 9e8c8ca583a483c90c465b87356af65650caeb6a Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Thu, 28 Nov 2024 18:34:48 +0100 Subject: [PATCH 07/20] webmail: fix dragging the corner of the compose popup when it's on top of a message view with an iframe (for an html message) the pointer events for moving the mouse would be consumed by the iframe. that broke resizing of the compose popup. we now disable pointerevents on the main ui when we are dragging the corner of the compose popup. this is similar to an earlier change about the draggable split bar between the message list and the message view (when showing an html message). --- webmail/webmail.js | 9 +++++++-- webmail/webmail.ts | 9 +++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/webmail/webmail.js b/webmail/webmail.js index c1c6d9737..863864e71 100644 --- a/webmail/webmail.js +++ b/webmail/webmail.js @@ -3005,9 +3005,13 @@ const compose = (opts, listMailboxes) => { borderRadius: '.25em', display: 'flex', flexDirection: 'column', - }), initWidth ? style({ width: initWidth + 'px' }) : [], initHeight ? style({ height: initHeight + 'px' }) : [], dom.div(css('composeResizeGrab', { position: 'absolute', marginTop: '-1em', marginLeft: '-1em', width: '1em', height: '1em', cursor: 'nw-resize' }), function mousedown(e) { + }), initWidth ? style({ width: initWidth + 'px' }) : [], initHeight ? style({ height: initHeight + 'px' }) : [], dom.div(css('composeResizeGrab', { position: 'absolute', marginTop: '-1em', marginLeft: '-1em', width: '1em', height: '1em', cursor: 'nw-resize' }), async function mousedown(e) { + // Disable pointer events on the message view. If it has an iframe with a message, + // mouse events while dragging would be consumed by the iframe, breaking our + // resize. + page.style.pointerEvents = 'none'; resizeLast = null; - startDrag(e, (e) => { + await startDrag(e, (e) => { if (resizeLast) { const bounds = composeElem.getBoundingClientRect(); const width = Math.round(bounds.width + resizeLast.x - e.clientX); @@ -3024,6 +3028,7 @@ const compose = (opts, listMailboxes) => { } resizeLast = { x: e.clientX, y: e.clientY }; }); + page.style.pointerEvents = ''; }), dom.form(css('composeForm', { flexGrow: '1', display: 'flex', diff --git a/webmail/webmail.ts b/webmail/webmail.ts index 525acd554..0798c14b2 100644 --- a/webmail/webmail.ts +++ b/webmail/webmail.ts @@ -1953,9 +1953,13 @@ const compose = (opts: ComposeOptions, listMailboxes: listMailboxes) => { initHeight ? style({height: initHeight+'px'}) : [], dom.div( css('composeResizeGrab', {position: 'absolute', marginTop: '-1em', marginLeft: '-1em', width: '1em', height: '1em', cursor: 'nw-resize'}), - function mousedown(e: MouseEvent) { + async function mousedown(e: MouseEvent) { + // Disable pointer events on the message view. If it has an iframe with a message, + // mouse events while dragging would be consumed by the iframe, breaking our + // resize. + page.style.pointerEvents = 'none' resizeLast = null - startDrag(e, (e: MouseEvent) => { + await startDrag(e, (e: MouseEvent) => { if (resizeLast) { const bounds = composeElem.getBoundingClientRect() const width = Math.round(bounds.width + resizeLast.x - e.clientX) @@ -1972,6 +1976,7 @@ const compose = (opts: ComposeOptions, listMailboxes: listMailboxes) => { } resizeLast = {x: e.clientX, y: e.clientY} }) + page.style.pointerEvents = '' }, ), dom.form( From 96d86ad6f1e6ede1a31ddaeecb02666cd553dcac Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Fri, 29 Nov 2024 10:17:07 +0100 Subject: [PATCH 08/20] add ability to include custom css & js in web interface (webmail, webaccount, webadmin), and use css variables in webmail for easier customization if files {webmail,webaccount,webadmin}.{css,js} exist in the configdir (where the mox.conf file lives), their contents are included in the web apps. the webmail now uses css variables, mostly for colors. so you can write a custom webmail.css that changes the variables, e.g.: :root { --color: blue } you can also look at css class names and override their styles. in the future, we may want to make some css variables configurable in the per-user settings in the webmail. should reduce the number of variables first. any custom javascript is loaded first. if it defines a global function "moxBeforeDisplay", that is called each time a page loads (after authentication) with the DOM element of the page content as parameter. the webmail is a single persistent page. this can be used to make some changes to the DOM, e.g. inserting some elements. we'll have to see how well this works in practice. perhaps some patterns emerge (e.g. adding a logo), and we can make those use-cases easier to achieve. helps partially with issue #114, and based on questions from laura-lilly on matrix. --- mox-/webappfile.go | 82 +++++++++++++++++--- webaccount/account.go | 9 ++- webaccount/account.html | 6 +- webaccount/account.js | 70 +++++++++-------- webaccount/account.ts | 79 ++++++++++--------- webadmin/admin.go | 9 ++- webadmin/admin.html | 6 +- webadmin/admin.js | 124 ++++++++++++++++-------------- webadmin/admin.ts | 127 +++++++++++++++++-------------- webmail/lib.ts | 165 ++++++++++++++++++++++++++++------------ webmail/msg.html | 7 ++ webmail/msg.js | 156 +++++++++++++++++++++++++------------ webmail/msg.ts | 8 +- webmail/text.html | 7 ++ webmail/text.js | 156 +++++++++++++++++++++++++------------ webmail/text.ts | 8 +- webmail/webmail.go | 65 ++++++++++++---- webmail/webmail.html | 6 +- webmail/webmail.js | 153 +++++++++++++++++++++++++------------ webmail/webmail.ts | 5 ++ 20 files changed, 834 insertions(+), 414 deletions(-) diff --git a/mox-/webappfile.go b/mox-/webappfile.go index 6eaeac085..ff879401c 100644 --- a/mox-/webappfile.go +++ b/mox-/webappfile.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "io/fs" "log/slog" "net/http" "os" @@ -25,6 +26,7 @@ import ( type WebappFile struct { HTML, JS []byte // Embedded html/js data. HTMLPath, JSPath string // Paths to load html/js from during development. + CustomStem string // For trying to read css/js customizations from $configdir/$stem.{css,js}. sync.Mutex combined []byte @@ -107,28 +109,82 @@ func (a *WebappFile) Serve(ctx context.Context, log mlog.Log, w http.ResponseWri } } + // Check mtime of css/js files. + var haveCustomCSS, haveCustomJS bool + checkCustomMtime := func(ext string, have *bool) bool { + path := ConfigDirPath(a.CustomStem + "." + ext) + if fi, err := os.Stat(path); err != nil { + if !errors.Is(err, fs.ErrNotExist) { + a.serverError(log, w, err, "stat customization file") + return false + } + } else if mtm := fi.ModTime(); mtm.After(diskmtime) { + diskmtime = mtm + *have = true + } + return true + } + if !checkCustomMtime("css", &haveCustomCSS) || !checkCustomMtime("js", &haveCustomJS) { + return + } + // Detect removal of custom files. + if fi, err := os.Stat(ConfigDirPath(".")); err == nil && fi.ModTime().After(diskmtime) { + diskmtime = fi.ModTime() + } + + a.Lock() + refreshdisk = refreshdisk || diskmtime.After(a.mtime) + a.Unlock() + gz := AcceptsGzip(r) var out []byte var mtime time.Time var origSize int64 - func() { + ok := func() bool { a.Lock() defer a.Unlock() if refreshdisk || a.combined == nil { - script := []byte(``) - index := bytes.Index(html, script) - if index < 0 { - a.serverError(log, w, errors.New("script not found"), "generating combined html") - return + var customCSS, customJS []byte + var err error + if haveCustomCSS { + customCSS, err = os.ReadFile(ConfigDirPath(a.CustomStem + ".css")) + if err != nil { + a.serverError(log, w, err, "read custom css file") + return false + } + } + if haveCustomJS { + customJS, err = os.ReadFile(ConfigDirPath(a.CustomStem + ".js")) + if err != nil { + a.serverError(log, w, err, "read custom js file") + return false + } + } + + cssp := []byte(`/* css placeholder */`) + cssi := bytes.Index(html, cssp) + if cssi < 0 { + a.serverError(log, w, errors.New("css placeholder not found"), "generating combined html") + return false + } + jsp := []byte(`/* js placeholder */`) + jsi := bytes.Index(html, jsp) + if jsi < 0 { + a.serverError(log, w, errors.New("js placeholder not found"), "generating combined html") + return false } var b bytes.Buffer - b.Write(html[:index]) - fmt.Fprintf(&b, "") - b.Write(html[index+len(script):]) + b.Write(html[jsi+len(jsp):]) out = b.Bytes() a.combined = out if refreshdisk { @@ -152,7 +208,7 @@ func (a *WebappFile) Serve(ctx context.Context, log mlog.Log, w http.ResponseWri } if err != nil { a.serverError(log, w, err, "gzipping combined html") - return + return false } a.combinedGzip = b.Bytes() } @@ -160,7 +216,11 @@ func (a *WebappFile) Serve(ctx context.Context, log mlog.Log, w http.ResponseWri out = a.combinedGzip } mtime = a.mtime + return true }() + if !ok { + return + } w.Header().Set("Content-Type", "text/html; charset=utf-8") http.ServeContent(gzipInjector{w, gz, origSize}, r, "", mtime, bytes.NewReader(out)) diff --git a/webaccount/account.go b/webaccount/account.go index ec2f4e092..d5cef38b5 100644 --- a/webaccount/account.go +++ b/webaccount/account.go @@ -51,10 +51,11 @@ var accountHTML []byte var accountJS []byte var webaccountFile = &mox.WebappFile{ - HTML: accountHTML, - JS: accountJS, - HTMLPath: filepath.FromSlash("webaccount/account.html"), - JSPath: filepath.FromSlash("webaccount/account.js"), + HTML: accountHTML, + JS: accountJS, + HTMLPath: filepath.FromSlash("webaccount/account.html"), + JSPath: filepath.FromSlash("webaccount/account.js"), + CustomStem: "webaccount", } var accountDoc = mustParseAPI("account", accountapiJSON) diff --git a/webaccount/account.html b/webaccount/account.html index 4cd6fee05..0c18d503c 100644 --- a/webaccount/account.html +++ b/webaccount/account.html @@ -30,10 +30,14 @@ .autosize { display: inline-grid; max-width: 90vw; } .autosize.input { grid-area: 1 / 2; } .autosize::after { content: attr(data-value); margin-right: 1em; line-height: 0; visibility: hidden; white-space: pre-wrap; overflow-x: hidden; } + +/* css placeholder */
Loading...
- + diff --git a/webaccount/account.js b/webaccount/account.js index 26904c594..e30e6c4e7 100644 --- a/webaccount/account.js +++ b/webaccount/account.js @@ -1396,7 +1396,7 @@ const index = async () => { body.setAttribute('rows', '' + Math.min(40, (body.value.split('\n').length + 1))); onchange(); }; - dom._kids(page, crumbs('Mox Account'), dom.div('Default domain: ', acc.DNSDomain.ASCII ? domainString(acc.DNSDomain) : '(none)'), dom.br(), fullNameForm = dom.form(fullNameFieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), 'Full name', dom.br(), fullName = dom.input(attr.value(acc.FullName), attr.title('Name to use in From header when composing messages. Can be overridden per configured address.'))), ' ', dom.submitbutton('Save')), async function submit(e) { + const root = dom.div(crumbs('Mox Account'), dom.div('Default domain: ', acc.DNSDomain.ASCII ? domainString(acc.DNSDomain) : '(none)'), dom.br(), fullNameForm = dom.form(fullNameFieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), 'Full name', dom.br(), fullName = dom.input(attr.value(acc.FullName), attr.title('Name to use in From header when composing messages. Can be overridden per configured address.'))), ' ', dom.submitbutton('Save')), async function submit(e) { e.preventDefault(); await check(fullNameFieldset, client.AccountSaveFullName(fullName.value)); fullName.setAttribute('value', fullName.value); @@ -1590,33 +1590,36 @@ const index = async () => { mailboxPrefixHint.style.display = ''; })), mailboxPrefixHint = dom.p(style({ display: 'none', fontStyle: 'italic', marginTop: '.5ex' }), 'If set, any mbox/maildir path with this prefix will have it stripped before importing. For example, if all mailboxes are in a directory "Takeout", specify that path in the field above so mailboxes like "Takeout/Inbox.mbox" are imported into a mailbox called "Inbox" instead of "Takeout/Inbox".')), dom.div(dom.submitbutton('Upload and import'), dom.p(style({ fontStyle: 'italic', marginTop: '.5ex' }), 'The file is uploaded first, then its messages are imported, finally messages are matched for threading. Importing is done in a transaction, you can abort the entire import before it is finished.')))), importAbortBox = dom.div(), // Outside fieldset because it gets disabled, above progress because may be scrolling it down quickly with problems. importProgress = dom.div(style({ display: 'none' })), dom.br(), footer); - // Try to show the progress of an earlier import session. The user may have just - // refreshed the browser. - let importToken; - try { - importToken = window.sessionStorage.getItem('ImportToken') || ''; - } - catch (err) { - console.log('looking up ImportToken in session storage', { err }); - return; - } - if (!importToken) { - return; - } - importFieldset.disabled = true; - dom._kids(importProgress, dom.div(dom.div('Reconnecting to import...'))); - importProgress.style.display = ''; - importTrack(importToken) - .catch(() => { - if (window.confirm('Error reconnecting to import. Remove this import session?')) { - window.sessionStorage.removeItem('ImportToken'); - dom._kids(importProgress); - importProgress.style.display = 'none'; + (async () => { + // Try to show the progress of an earlier import session. The user may have just + // refreshed the browser. + let importToken; + try { + importToken = window.sessionStorage.getItem('ImportToken') || ''; } - }) - .finally(() => { - importFieldset.disabled = false; - }); + catch (err) { + console.log('looking up ImportToken in session storage', { err }); + return; + } + if (!importToken) { + return; + } + importFieldset.disabled = true; + dom._kids(importProgress, dom.div(dom.div('Reconnecting to import...'))); + importProgress.style.display = ''; + importTrack(importToken) + .catch(() => { + if (window.confirm('Error reconnecting to import. Remove this import session?')) { + window.sessionStorage.removeItem('ImportToken'); + dom._kids(importProgress); + importProgress.style.display = 'none'; + } + }) + .finally(() => { + importFieldset.disabled = false; + }); + })(); + return root; }; const destination = async (name) => { const [acc] = await client.Account(); @@ -1692,7 +1695,7 @@ const destination = async (name) => { let fullName; let saveButton; const addresses = [name, ...Object.keys(acc.Destinations || {}).filter(a => !a.startsWith('@') && a !== name)]; - dom._kids(page, crumbs(crumblink('Mox Account', '#'), 'Destination ' + name), dom.div(dom.span('Default mailbox', attr.title('Default mailbox where email for this recipient is delivered to if it does not match any ruleset. Default is Inbox.')), dom.br(), defaultMailbox = dom.input(attr.value(dest.Mailbox), attr.placeholder('Inbox'))), dom.br(), dom.div(dom.span('Full name', attr.title('Name to use in From header when composing messages. If not set, the account default full name is used.')), dom.br(), fullName = dom.input(attr.value(dest.FullName))), 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.'), dom.p('"Is Forward" does not affect matching, but changes prevents the sending mail server from being included in future junk classifications by clearing fields related to the forwarding email server (IP address, EHLO domain, MAIL FROM domain and a matching DKIM domain), and prevents DMARC rejects for forwarded messages.'), dom.p('"List allow domain" does not affect matching, but skips the regular spam checks if one of the verified domains is a (sub)domain of the domain mentioned here.'), dom.p('"Accept rejects to mailbox" does not affect matching, but causes messages classified as junk to be accepted and delivered to this mailbox, instead of being rejected during the SMTP transaction. Useful for incoming forwarded messages where rejecting incoming messages may cause the forwarding server to stop forwarding.'), dom.table(dom.thead(dom.tr(dom.th('SMTP "MAIL FROM" regexp', attr.title('Matches if this regular expression matches (a substring of) the SMTP MAIL FROM address (not the message From-header). E.g. user@example.org.')), dom.th('Message "From" address regexp', attr.title('Matches if this regular expression matches (a substring of) the single address in the message From header.')), dom.th('Verified domain', attr.title('Matches if this domain matches an SPF- and/or DKIM-verified (sub)domain.')), dom.th('Headers regexp', attr.title('Matches if these header field/value regular expressions all match (substrings of) the message headers. Header fields and valuees are converted to lower case before matching. Whitespace is trimmed from the value before matching. A header field can occur multiple times in a message, only one instance has to match. For mailing lists, you could match on ^list-id$ with the value typically the mailing list address in angled brackets with @ replaced with a dot, e.g. .')), dom.th('Is Forward', attr.title("Influences spam filtering only, this option does not change whether a message matches this ruleset. Can only be used together with SMTPMailFromRegexp and VerifiedDomain. SMTPMailFromRegexp must be set to the address used to deliver the forwarded message, e.g. '^user(|\\+.*)@forward\\.example$'. Changes to junk analysis: 1. Messages are not rejected for failing a DMARC policy, because a legitimate forwarded message without valid/intact/aligned DKIM signature would be rejected because any verified SPF domain will be 'unaligned', of the forwarding mail server. 2. The sending mail server IP address, and sending EHLO and MAIL FROM domains and matching DKIM domain aren't used in future reputation-based spam classifications (but other verified DKIM domains are) because the forwarding server is not a useful spam signal for future messages.")), dom.th('List allow domain', attr.title("Influences spam filtering only, this option does not change whether a message matches this ruleset. If this domain matches an SPF- and/or DKIM-verified (sub)domain, the message is accepted without further spam checks, such as a junk filter or DMARC reject evaluation. DMARC rejects should not apply for mailing lists that are not configured to rewrite the From-header of messages that don't have a passing DKIM signature of the From-domain. Otherwise, by rejecting messages, you may be automatically unsubscribed from the mailing list. The assumption is that mailing lists do their own spam filtering/moderation.")), dom.th('Allow rejects to mailbox', attr.title("Influences spam filtering only, this option does not change whether a message matches this ruleset. If a message is classified as spam, it isn't rejected during the SMTP transaction (the normal behaviour), but accepted during the SMTP transaction and delivered to the specified mailbox. The specified mailbox is not automatically cleaned up like the account global Rejects mailbox, unless set to that Rejects mailbox.")), dom.th('Mailbox', attr.title('Mailbox to deliver to if this ruleset matches.')), dom.th('Comment', attr.title('Free-form comments.')), dom.th('Action'))), rulesetsTbody, dom.tfoot(dom.tr(dom.td(attr.colspan('9')), dom.td(dom.clickbutton('Add ruleset', function click() { + return dom.div(crumbs(crumblink('Mox Account', '#'), 'Destination ' + name), dom.div(dom.span('Default mailbox', attr.title('Default mailbox where email for this recipient is delivered to if it does not match any ruleset. Default is Inbox.')), dom.br(), defaultMailbox = dom.input(attr.value(dest.Mailbox), attr.placeholder('Inbox'))), dom.br(), dom.div(dom.span('Full name', attr.title('Name to use in From header when composing messages. If not set, the account default full name is used.')), dom.br(), fullName = dom.input(attr.value(dest.FullName))), 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.'), dom.p('"Is Forward" does not affect matching, but changes prevents the sending mail server from being included in future junk classifications by clearing fields related to the forwarding email server (IP address, EHLO domain, MAIL FROM domain and a matching DKIM domain), and prevents DMARC rejects for forwarded messages.'), dom.p('"List allow domain" does not affect matching, but skips the regular spam checks if one of the verified domains is a (sub)domain of the domain mentioned here.'), dom.p('"Accept rejects to mailbox" does not affect matching, but causes messages classified as junk to be accepted and delivered to this mailbox, instead of being rejected during the SMTP transaction. Useful for incoming forwarded messages where rejecting incoming messages may cause the forwarding server to stop forwarding.'), dom.table(dom.thead(dom.tr(dom.th('SMTP "MAIL FROM" regexp', attr.title('Matches if this regular expression matches (a substring of) the SMTP MAIL FROM address (not the message From-header). E.g. user@example.org.')), dom.th('Message "From" address regexp', attr.title('Matches if this regular expression matches (a substring of) the single address in the message From header.')), dom.th('Verified domain', attr.title('Matches if this domain matches an SPF- and/or DKIM-verified (sub)domain.')), dom.th('Headers regexp', attr.title('Matches if these header field/value regular expressions all match (substrings of) the message headers. Header fields and valuees are converted to lower case before matching. Whitespace is trimmed from the value before matching. A header field can occur multiple times in a message, only one instance has to match. For mailing lists, you could match on ^list-id$ with the value typically the mailing list address in angled brackets with @ replaced with a dot, e.g. .')), dom.th('Is Forward', attr.title("Influences spam filtering only, this option does not change whether a message matches this ruleset. Can only be used together with SMTPMailFromRegexp and VerifiedDomain. SMTPMailFromRegexp must be set to the address used to deliver the forwarded message, e.g. '^user(|\\+.*)@forward\\.example$'. Changes to junk analysis: 1. Messages are not rejected for failing a DMARC policy, because a legitimate forwarded message without valid/intact/aligned DKIM signature would be rejected because any verified SPF domain will be 'unaligned', of the forwarding mail server. 2. The sending mail server IP address, and sending EHLO and MAIL FROM domains and matching DKIM domain aren't used in future reputation-based spam classifications (but other verified DKIM domains are) because the forwarding server is not a useful spam signal for future messages.")), dom.th('List allow domain', attr.title("Influences spam filtering only, this option does not change whether a message matches this ruleset. If this domain matches an SPF- and/or DKIM-verified (sub)domain, the message is accepted without further spam checks, such as a junk filter or DMARC reject evaluation. DMARC rejects should not apply for mailing lists that are not configured to rewrite the From-header of messages that don't have a passing DKIM signature of the From-domain. Otherwise, by rejecting messages, you may be automatically unsubscribed from the mailing list. The assumption is that mailing lists do their own spam filtering/moderation.")), dom.th('Allow rejects to mailbox', attr.title("Influences spam filtering only, this option does not change whether a message matches this ruleset. If a message is classified as spam, it isn't rejected during the SMTP transaction (the normal behaviour), but accepted during the SMTP transaction and delivered to the specified mailbox. The specified mailbox is not automatically cleaned up like the account global Rejects mailbox, unless set to that Rejects mailbox.")), dom.th('Mailbox', attr.title('Mailbox to deliver to if this ruleset matches.')), dom.th('Comment', attr.title('Free-form comments.')), dom.th('Action'))), rulesetsTbody, dom.tfoot(dom.tr(dom.td(attr.colspan('9')), dom.td(dom.clickbutton('Add ruleset', function click() { addRulesetsRow({ SMTPMailFromRegexp: '', MsgFromRegexp: '', @@ -1743,15 +1746,20 @@ const init = async () => { const t = h.split('/'); page.classList.add('loading'); try { + let root; if (h === '') { - await index(); + root = await index(); } else if (t[0] === 'destinations' && t.length === 2) { - await destination(t[1]); + root = await destination(t[1]); } else { - dom._kids(page, 'page not found'); + root = dom.div('page not found'); + } + if (window.moxBeforeDisplay) { + moxBeforeDisplay(root); } + dom._kids(page, root); } catch (err) { console.log({ err }); diff --git a/webaccount/account.ts b/webaccount/account.ts index c50354805..631f9cb67 100644 --- a/webaccount/account.ts +++ b/webaccount/account.ts @@ -5,6 +5,8 @@ declare let page: HTMLElement declare let moxversion: string declare let moxgoos: string declare let moxgoarch: string +// From customization script. +declare let moxBeforeDisplay: (webmailroot: HTMLElement) => void const login = async (reason: string) => { return new Promise((resolve: (v: string) => void, _) => { @@ -737,7 +739,7 @@ const index = async () => { onchange() } - dom._kids(page, + const root = dom.div( crumbs('Mox Account'), dom.div( 'Default domain: ', @@ -1390,36 +1392,40 @@ const index = async () => { footer, ) - // Try to show the progress of an earlier import session. The user may have just - // refreshed the browser. - let importToken: string - try { - importToken = window.sessionStorage.getItem('ImportToken') || '' - } catch (err) { - console.log('looking up ImportToken in session storage', {err}) - return - } - if (!importToken) { - return - } - importFieldset.disabled = true - dom._kids(importProgress, - dom.div( - dom.div('Reconnecting to import...'), - ), - ) - importProgress.style.display = '' - importTrack(importToken) - .catch(() => { - if (window.confirm('Error reconnecting to import. Remove this import session?')) { - window.sessionStorage.removeItem('ImportToken') - dom._kids(importProgress) - importProgress.style.display = 'none' + ;(async () => { + // Try to show the progress of an earlier import session. The user may have just + // refreshed the browser. + let importToken: string + try { + importToken = window.sessionStorage.getItem('ImportToken') || '' + } catch (err) { + console.log('looking up ImportToken in session storage', {err}) + return } - }) - .finally(() => { - importFieldset.disabled = false - }) + if (!importToken) { + return + } + importFieldset.disabled = true + dom._kids(importProgress, + dom.div( + dom.div('Reconnecting to import...'), + ), + ) + importProgress.style.display = '' + importTrack(importToken) + .catch(() => { + if (window.confirm('Error reconnecting to import. Remove this import session?')) { + window.sessionStorage.removeItem('ImportToken') + dom._kids(importProgress) + importProgress.style.display = 'none' + } + }) + .finally(() => { + importFieldset.disabled = false + }) + })() + + return root } const destination = async (name: string) => { @@ -1552,7 +1558,7 @@ const destination = async (name: string) => { const addresses = [name, ...Object.keys(acc.Destinations || {}).filter(a => !a.startsWith('@') && a !== name)] - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Account', '#'), 'Destination ' + name, @@ -1664,13 +1670,18 @@ const init = async () => { const t = h.split('/') page.classList.add('loading') try { + let root: HTMLElement if (h === '') { - await index() + root = await index() } else if (t[0] === 'destinations' && t.length === 2) { - await destination(t[1]) + root = await destination(t[1]) } else { - dom._kids(page, 'page not found') + root = dom.div('page not found') + } + if ((window as any).moxBeforeDisplay) { + moxBeforeDisplay(root) } + dom._kids(page, root) } catch (err) { console.log({err}) window.alert('Error: ' + errmsg(err)) diff --git a/webadmin/admin.go b/webadmin/admin.go index 10f38e92e..207ba5d32 100644 --- a/webadmin/admin.go +++ b/webadmin/admin.go @@ -80,10 +80,11 @@ var adminHTML []byte var adminJS []byte var webadminFile = &mox.WebappFile{ - HTML: adminHTML, - JS: adminJS, - HTMLPath: filepath.FromSlash("webadmin/admin.html"), - JSPath: filepath.FromSlash("webadmin/admin.js"), + HTML: adminHTML, + JS: adminJS, + HTMLPath: filepath.FromSlash("webadmin/admin.html"), + JSPath: filepath.FromSlash("webadmin/admin.js"), + CustomStem: "webadmin", } var adminDoc = mustParseAPI("admin", adminapiJSON) diff --git a/webadmin/admin.html b/webadmin/admin.html index e0887c469..6e0160b57 100644 --- a/webadmin/admin.html +++ b/webadmin/admin.html @@ -36,10 +36,14 @@ #page.loading, .loadstart { opacity: 0.1; animation: fadeout 1s ease-out; } @keyframes fadein { 0% { opacity: 0 } 100% { opacity: 1 } } @keyframes fadeout { 0% { opacity: 1 } 100% { opacity: 0.1 } } + +/* css placeholder */
Loading...
- + diff --git a/webadmin/admin.js b/webadmin/admin.js index b1e8e2c48..8dacf740f 100644 --- a/webadmin/admin.js +++ b/webadmin/admin.js @@ -1921,7 +1921,7 @@ const index = async () => { let recvIDFieldset; let recvID; let cidElem; - dom._kids(page, crumbs('Mox Admin'), checkUpdatesEnabled ? [] : dom.p(box(yellow, 'Warning: Checking for updates has not been enabled in mox.conf (CheckUpdates: true).', dom.br(), 'Make sure you stay up to date through another mechanism!', dom.br(), 'You have a responsibility to keep the internet-connected software you run up to date and secure!', dom.br(), 'See ', link('https://updates.xmox.nl/changelog'))), dom.p(dom.a('Accounts', attr.href('#accounts')), dom.br(), dom.a('Queue', attr.href('#queue')), ' (' + queueSize + ')', dom.br(), dom.a('Webhook queue', attr.href('#webhookqueue')), ' (' + hooksQueueSize + ')', dom.br()), dom.h2('Domains'), (domains || []).length === 0 ? box(red, 'No domains') : + return dom.div(crumbs('Mox Admin'), checkUpdatesEnabled ? [] : dom.p(box(yellow, 'Warning: Checking for updates has not been enabled in mox.conf (CheckUpdates: true).', dom.br(), 'Make sure you stay up to date through another mechanism!', dom.br(), 'You have a responsibility to keep the internet-connected software you run up to date and secure!', dom.br(), 'See ', link('https://updates.xmox.nl/changelog'))), dom.p(dom.a('Accounts', attr.href('#accounts')), dom.br(), dom.a('Queue', attr.href('#queue')), ' (' + queueSize + ')', dom.br(), dom.a('Webhook queue', attr.href('#webhookqueue')), ' (' + hooksQueueSize + ')', dom.br()), dom.h2('Domains'), (domains || []).length === 0 ? box(red, 'No domains') : dom.ul((domains || []).map(d => dom.li(dom.a(attr.href('#domains/' + domainName(d)), domainString(d))))), dom.br(), dom.h2('Add domain'), dom.form(async function submit(e) { e.preventDefault(); e.stopPropagation(); @@ -1942,11 +1942,11 @@ const globalRoutes = async () => { client.Transports(), client.Config(), ]); - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), 'Routes'), RoutesEditor('global', transports, config.Routes || [], async (routes) => await client.RoutesSave(routes))); + return dom.div(crumbs(crumblink('Mox Admin', '#'), 'Routes'), RoutesEditor('global', transports, config.Routes || [], async (routes) => await client.RoutesSave(routes))); }; const config = async () => { const [staticPath, dynamicPath, staticText, dynamicText] = await client.ConfigFiles(); - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), 'Config'), dom.h2(staticPath), dom.pre(dom._class('literal'), staticText), dom.h2(dynamicPath), dom.pre(dom._class('literal'), dynamicText)); + return dom.div(crumbs(crumblink('Mox Admin', '#'), 'Config'), dom.h2(staticPath), dom.pre(dom._class('literal'), staticText), dom.h2(dynamicPath), dom.pre(dom._class('literal'), dynamicText)); }; const loglevels = async () => { const loglevels = await client.LogLevels(); @@ -1955,7 +1955,7 @@ const loglevels = async () => { let fieldset; let pkg; let level; - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), 'Log levels'), dom.p('Note: changing a log level here only changes it for the current process. When mox restarts, it sets the log levels from the configuration file. Change mox.conf to keep the changes.'), dom.table(dom.thead(dom.tr(dom.th('Package', attr.title('Log levels can be configured per package. E.g. smtpserver, imapserver, dkim, dmarc, tlsrpt, etc.')), dom.th('Level', attr.title('If you set the log level to "trace", imap and smtp protocol transcripts will be logged. Sensitive authentication is replaced with "***" unless the level is >= "traceauth". Data is masked with "..." unless the level is "tracedata".')), dom.th('Action'))), dom.tbody(Object.entries(loglevels).map(t => { + return dom.div(crumbs(crumblink('Mox Admin', '#'), 'Log levels'), dom.p('Note: changing a log level here only changes it for the current process. When mox restarts, it sets the log levels from the configuration file. Change mox.conf to keep the changes.'), dom.table(dom.thead(dom.tr(dom.th('Package', attr.title('Log levels can be configured per package. E.g. smtpserver, imapserver, dkim, dmarc, tlsrpt, etc.')), dom.th('Level', attr.title('If you set the log level to "trace", imap and smtp protocol transcripts will be logged. Sensitive authentication is replaced with "***" unless the level is >= "traceauth". Data is masked with "..." unless the level is "tracedata".')), dom.th('Action'))), dom.tbody(Object.entries(loglevels).map(t => { let lvl; return dom.tr(dom.td(t[0] || '(default)'), dom.td(lvl = dom.select(levels.map(l => dom.option(l, t[1] === l ? attr.selected('') : [])))), dom.td(dom.clickbutton('Save', attr.title('Set new log level for package.'), async function click(e) { e.preventDefault(); @@ -2000,7 +2000,7 @@ const accounts = async () => { let domain; let account; let accountModified = false; - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), 'Accounts'), dom.h2('Accounts'), (accounts || []).length === 0 ? dom.p('No accounts') : + return dom.div(crumbs(crumblink('Mox Admin', '#'), 'Accounts'), dom.h2('Accounts'), (accounts || []).length === 0 ? dom.p('No accounts') : dom.ul((accounts || []).map(s => dom.li(dom.a(s, attr.href('#accounts/' + s))))), dom.br(), dom.h2('Add account'), dom.form(async function submit(e) { e.preventDefault(); e.stopPropagation(); @@ -2134,7 +2134,7 @@ const account = async (name) => { } return v * mult; }; - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), crumblink('Accounts', '#accounts'), name), dom.h2('Addresses'), dom.table(dom.thead(dom.tr(dom.th('Address'), dom.th('Action'))), dom.tbody(Object.keys(config.Destinations || {}).length === 0 ? dom.tr(dom.td(attr.colspan('2'), '(None, login disabled)')) : [], Object.keys(config.Destinations || {}).map(k => { + return dom.div(crumbs(crumblink('Mox Admin', '#'), crumblink('Accounts', '#accounts'), name), dom.h2('Addresses'), dom.table(dom.thead(dom.tr(dom.th('Address'), dom.th('Action'))), dom.tbody(Object.keys(config.Destinations || {}).length === 0 ? dom.tr(dom.td(attr.colspan('2'), '(None, login disabled)')) : [], Object.keys(config.Destinations || {}).map(k => { let v = k; const t = k.split('@'); if (t.length > 1) { @@ -2338,7 +2338,7 @@ const domain = async (d) => { window.location.reload(); // todo: reload only dkim section }, fieldset = dom.fieldset(dom.div(style({ display: 'flex', gap: '1em' }), dom.div(dom.label(style({ display: 'block', marginBottom: '1ex' }), 'Selector', attr.title('Used in the DKIM-Signature header, and used to form a DNS record under ._domainkey..'), dom.div(selector = dom.input(attr.required(''), attr.value(defaultSelector())))), dom.label(style({ display: 'block', marginBottom: '1ex' }), 'Algorithm', attr.title('For signing messages. RSA is common at the time of writing, not all mail servers recognize ed25519 signature.'), dom.div(algorithm = dom.select(dom.option('rsa'), dom.option('ed25519')))), dom.label(style({ display: 'block', marginBottom: '1ex' }), 'Hash', attr.title("Used in signing messages. Don't use sha1 unless you understand the consequences."), dom.div(hash = dom.select(dom.option('sha256')))), dom.label(style({ display: 'block', marginBottom: '1ex' }), 'Canonicalization - header', attr.title('Canonicalization processes the message headers before signing. Relaxed allows more whitespace changes, making it more likely for DKIM signatures to validate after transit through servers that make whitespace modifications. Simple is more strict.'), dom.div(canonHeader = dom.select(dom.option('relaxed'), dom.option('simple')))), dom.label(style({ display: 'block', marginBottom: '1ex' }), 'Canonicalization - body', attr.title('Like canonicalization for headers, but for the bodies.'), dom.div(canonBody = dom.select(dom.option('relaxed'), dom.option('simple')))), dom.label(style({ display: 'block', marginBottom: '1ex' }), 'Signature lifetime', attr.title('How long a signature remains valid. Should be as long as a message may take to be delivered. The signature must be valid at the time a message is being delivered to the final destination.'), dom.div(lifetime = dom.input(attr.value('3d'), attr.required('')))), dom.label(style({ display: 'block', marginBottom: '1ex' }), 'Seal headers', attr.title("DKIM-signatures cover headers. If headers are not sealed, additional message headers can be added with the same key without invalidating the signature. This may confuse software about which headers are trustworthy. Sealing is the safer option."), dom.div(seal = dom.input(attr.type('checkbox'), attr.checked(''))))), dom.div(dom.label(style({ display: 'block', marginBottom: '1ex' }), 'Headers (optional)', attr.title('Headers to sign. If left empty, a set of standard headers are signed. The (standard set of) headers are most easily edited after creating the selector/key.'), dom.div(headers = dom.textarea(attr.rows('15')))))), dom.div(dom.submitbutton('Add'))))); }; - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), 'Domain ' + domainString(dnsdomain)), dom.ul(dom.li(dom.a('Required DNS records', attr.href('#domains/' + d + '/dnsrecords'))), dom.li(dom.a('Check current actual DNS records and domain configuration', attr.href('#domains/' + d + '/dnscheck')))), dom.br(), dom.h2('Client configuration'), dom.p('If autoconfig/autodiscover does not work with an email client, use the settings below for this domain. Authenticate with email address and password. ', dom.span('Explicitly configure', attr.title('To prevent authentication mechanism downgrade attempts that may result in clients sending plain text passwords to a MitM.')), ' the first supported authentication mechanism: SCRAM-SHA-256-PLUS, SCRAM-SHA-1-PLUS, SCRAM-SHA-256, SCRAM-SHA-1, CRAM-MD5.'), dom.table(dom.thead(dom.tr(dom.th('Protocol'), dom.th('Host'), dom.th('Port'), dom.th('Listener'), dom.th('Note'))), dom.tbody((clientConfigs.Entries || []).map(e => dom.tr(dom.td(e.Protocol), dom.td(domainString(e.Host)), dom.td('' + e.Port), dom.td('' + e.Listener), dom.td('' + e.Note))))), dom.br(), dom.h2('DMARC aggregate reports summary'), renderDMARCSummaries(dmarcSummaries || []), dom.br(), dom.h2('TLS reports summary'), renderTLSRPTSummaries(tlsrptSummaries || []), dom.br(), dom.h2('Addresses'), dom.table(dom.thead(dom.tr(dom.th('Address'), dom.th('Account'), dom.th('Action'))), dom.tbody(Object.entries(localpartAccounts).map(t => dom.tr(dom.td(prewrap(t[0]) || '(catchall)'), dom.td(dom.a(t[1], attr.href('#accounts/' + t[1]))), dom.td(dom.clickbutton('Remove', async function click(e) { + return dom.div(crumbs(crumblink('Mox Admin', '#'), 'Domain ' + domainString(dnsdomain)), dom.ul(dom.li(dom.a('Required DNS records', attr.href('#domains/' + d + '/dnsrecords'))), dom.li(dom.a('Check current actual DNS records and domain configuration', attr.href('#domains/' + d + '/dnscheck')))), dom.br(), dom.h2('Client configuration'), dom.p('If autoconfig/autodiscover does not work with an email client, use the settings below for this domain. Authenticate with email address and password. ', dom.span('Explicitly configure', attr.title('To prevent authentication mechanism downgrade attempts that may result in clients sending plain text passwords to a MitM.')), ' the first supported authentication mechanism: SCRAM-SHA-256-PLUS, SCRAM-SHA-1-PLUS, SCRAM-SHA-256, SCRAM-SHA-1, CRAM-MD5.'), dom.table(dom.thead(dom.tr(dom.th('Protocol'), dom.th('Host'), dom.th('Port'), dom.th('Listener'), dom.th('Note'))), dom.tbody((clientConfigs.Entries || []).map(e => dom.tr(dom.td(e.Protocol), dom.td(domainString(e.Host)), dom.td('' + e.Port), dom.td('' + e.Listener), dom.td('' + e.Note))))), dom.br(), dom.h2('DMARC aggregate reports summary'), renderDMARCSummaries(dmarcSummaries || []), dom.br(), dom.h2('TLS reports summary'), renderTLSRPTSummaries(tlsrptSummaries || []), dom.br(), dom.h2('Addresses'), dom.table(dom.thead(dom.tr(dom.th('Address'), dom.th('Account'), dom.th('Action'))), dom.tbody(Object.entries(localpartAccounts).map(t => dom.tr(dom.td(prewrap(t[0]) || '(catchall)'), dom.td(dom.a(t[1], attr.href('#accounts/' + t[1]))), dom.td(dom.clickbutton('Remove', async function click(e) { e.preventDefault(); if (!window.confirm('Are you sure you want to remove this address? If it is a member of an alias, it will be removed from the alias.')) { return; @@ -2550,7 +2550,7 @@ const domainAlias = async (d, aliasLocalpart) => { let addFieldset; let addAddress; let delFieldset; - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), crumblink('Domain ' + domainString(domain.Domain), '#domains/' + d), 'Alias ' + aliasLocalpart + '@' + domainName(domain.Domain)), dom.h2('Alias'), dom.form(async function submit(e) { + return dom.div(crumbs(crumblink('Mox Admin', '#'), crumblink('Domain ' + domainString(domain.Domain), '#domains/' + d), 'Alias ' + aliasLocalpart + '@' + domainName(domain.Domain)), dom.h2('Alias'), dom.form(async function submit(e) { e.preventDefault(); e.stopPropagation(); check(aliasFieldset, client.AliasUpdate(aliasLocalpart, d, postPublic.checked, listMembers.checked, allowMsgFrom.checked)); @@ -2580,7 +2580,7 @@ const domainDNSRecords = async (d) => { client.DomainRecords(d), client.ParseDomain(d), ]); - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), crumblink('Domain ' + domainString(dnsdomain), '#domains/' + d), 'DNS Records'), dom.h1('Required DNS records'), dom.pre(dom._class('literal'), (records || []).join('\n')), dom.br()); + return dom.div(crumbs(crumblink('Mox Admin', '#'), crumblink('Domain ' + domainString(dnsdomain), '#domains/' + d), 'DNS Records'), dom.h1('Required DNS records'), dom.pre(dom._class('literal'), (records || []).join('\n')), dom.br()); }; const domainDNSCheck = async (d) => { const [checks, dnsdomain] = await Promise.all([ @@ -2668,16 +2668,16 @@ const domainDNSCheck = async (d) => { const detailsAutodiscover = !checks.Autodiscover.Records ? [] : [ dom.table(dom.thead(dom.tr(dom.th('Host'), dom.th('Port'), dom.th('Priority'), dom.th('Weight'), dom.th('IPs'))), dom.tbody((checks.Autodiscover.Records || []).map(r => dom.tr([r.Target, r.Port, r.Priority, r.Weight, (r.IPs || []).join(', ')].map(s => dom.td('' + s)))))), ]; - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), crumblink('Domain ' + domainString(dnsdomain), '#domains/' + d), 'Check DNS'), dom.h1('DNS records and domain configuration check'), resultSection('DNSSEC', checks.DNSSEC, detailsDNSSEC), resultSection('IPRev', checks.IPRev, detailsIPRev), resultSection('MX', checks.MX, detailsMX), resultSection('TLS', checks.TLS, detailsTLS), resultSection('DANE', checks.DANE, detailsDANE), resultSection('SPF', checks.SPF, detailsSPF), resultSection('DKIM', checks.DKIM, detailsDKIM), resultSection('DMARC', checks.DMARC, detailsDMARC), resultSection('Host TLSRPT', checks.HostTLSRPT, detailsTLSRPT(checks.HostTLSRPT)), resultSection('Domain TLSRPT', checks.DomainTLSRPT, detailsTLSRPT(checks.DomainTLSRPT)), resultSection('MTA-STS', checks.MTASTS, detailsMTASTS), resultSection('SRV conf', checks.SRVConf, detailsSRVConf), resultSection('Autoconf', checks.Autoconf, detailsAutoconf), resultSection('Autodiscover', checks.Autodiscover, detailsAutodiscover), dom.br()); + return dom.div(crumbs(crumblink('Mox Admin', '#'), crumblink('Domain ' + domainString(dnsdomain), '#domains/' + d), 'Check DNS'), dom.h1('DNS records and domain configuration check'), resultSection('DNSSEC', checks.DNSSEC, detailsDNSSEC), resultSection('IPRev', checks.IPRev, detailsIPRev), resultSection('MX', checks.MX, detailsMX), resultSection('TLS', checks.TLS, detailsTLS), resultSection('DANE', checks.DANE, detailsDANE), resultSection('SPF', checks.SPF, detailsSPF), resultSection('DKIM', checks.DKIM, detailsDKIM), resultSection('DMARC', checks.DMARC, detailsDMARC), resultSection('Host TLSRPT', checks.HostTLSRPT, detailsTLSRPT(checks.HostTLSRPT)), resultSection('Domain TLSRPT', checks.DomainTLSRPT, detailsTLSRPT(checks.DomainTLSRPT)), resultSection('MTA-STS', checks.MTASTS, detailsMTASTS), resultSection('SRV conf', checks.SRVConf, detailsSRVConf), resultSection('Autoconf', checks.Autoconf, detailsAutoconf), resultSection('Autodiscover', checks.Autodiscover, detailsAutodiscover), dom.br()); }; const dmarcIndex = async () => { - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), 'DMARC'), dom.ul(dom.li(dom.a(attr.href('#dmarc/reports'), 'Reports'), ', incoming DMARC aggregate reports.'), dom.li(dom.a(attr.href('#dmarc/evaluations'), 'Evaluations'), ', for outgoing DMARC aggregate reports.'))); + return dom.div(crumbs(crumblink('Mox Admin', '#'), 'DMARC'), dom.ul(dom.li(dom.a(attr.href('#dmarc/reports'), 'Reports'), ', incoming DMARC aggregate reports.'), dom.li(dom.a(attr.href('#dmarc/evaluations'), 'Evaluations'), ', for outgoing DMARC aggregate reports.'))); }; const dmarcReports = async () => { const end = new Date(); const start = new Date(new Date().getTime() - 30 * 24 * 3600 * 1000); const summaries = await client.DMARCSummaries(start, end, ""); - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), crumblink('DMARC', '#dmarc'), 'Aggregate reporting summary'), dom.p('DMARC reports are periodically sent by other mail servers that received an email message with a "From" header with our domain. Domains can have a DMARC DNS record that asks other mail servers to send these aggregate reports for analysis.'), renderDMARCSummaries(summaries || [])); + return dom.div(crumbs(crumblink('Mox Admin', '#'), crumblink('DMARC', '#dmarc'), 'Aggregate reporting summary'), dom.p('DMARC reports are periodically sent by other mail servers that received an email message with a "From" header with our domain. Domains can have a DMARC DNS record that asks other mail servers to send these aggregate reports for analysis.'), renderDMARCSummaries(summaries || [])); }; const renderDMARCSummaries = (summaries) => { return [ @@ -2702,7 +2702,7 @@ const dmarcEvaluations = async () => { let until; let comment; const nextmonth = new Date(new Date().getTime() + 31 * 24 * 3600 * 1000); - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), crumblink('DMARC', '#dmarc'), 'Evaluations'), dom.p('Incoming messages are checked against the DMARC policy of the domain in the message From header. If the policy requests reporting on the resulting evaluations, they are stored in the database. Each interval of 1 to 24 hours, the evaluations may be sent to a reporting address specified in the domain\'s DMARC policy. Not all evaluations are a reason to send a report, but if a report is sent all evaluations are included.'), dom.table(dom._class('hover'), dom.thead(dom.tr(dom.th('Domain', attr.title('Domain in the message From header. Keep in mind these can be forged, so this does not necessarily mean someone from this domain authentically tried delivering email.')), dom.th('Dispositions', attr.title('Unique dispositions occurring in report.')), dom.th('Evaluations', attr.title('Total number of message delivery attempts, including retries.')), dom.th('Send report', attr.title('Whether the current evaluations will cause a report to be sent.')))), dom.tbody(Object.entries(evalStats).sort((a, b) => a[0] < b[0] ? -1 : 1).map(t => dom.tr(dom.td(dom.a(attr.href('#dmarc/evaluations/' + domainName(t[1].Domain)), domainString(t[1].Domain))), dom.td((t[1].Dispositions || []).join(' ')), dom.td(style({ textAlign: 'right' }), '' + t[1].Count), dom.td(style({ textAlign: 'right' }), t[1].SendReport ? '✓' : ''))), isEmpty(evalStats) ? dom.tr(dom.td(attr.colspan('3'), 'No evaluations.')) : [])), dom.br(), dom.br(), dom.h2('Suppressed reporting addresses'), dom.p('In practice, sending a DMARC report to a reporting address can cause DSN to be sent back. Such addresses can be added to a suppression list for a period, to reduce noise in the postmaster mailbox.'), dom.form(async function submit(e) { + return dom.div(crumbs(crumblink('Mox Admin', '#'), crumblink('DMARC', '#dmarc'), 'Evaluations'), dom.p('Incoming messages are checked against the DMARC policy of the domain in the message From header. If the policy requests reporting on the resulting evaluations, they are stored in the database. Each interval of 1 to 24 hours, the evaluations may be sent to a reporting address specified in the domain\'s DMARC policy. Not all evaluations are a reason to send a report, but if a report is sent all evaluations are included.'), dom.table(dom._class('hover'), dom.thead(dom.tr(dom.th('Domain', attr.title('Domain in the message From header. Keep in mind these can be forged, so this does not necessarily mean someone from this domain authentically tried delivering email.')), dom.th('Dispositions', attr.title('Unique dispositions occurring in report.')), dom.th('Evaluations', attr.title('Total number of message delivery attempts, including retries.')), dom.th('Send report', attr.title('Whether the current evaluations will cause a report to be sent.')))), dom.tbody(Object.entries(evalStats).sort((a, b) => a[0] < b[0] ? -1 : 1).map(t => dom.tr(dom.td(dom.a(attr.href('#dmarc/evaluations/' + domainName(t[1].Domain)), domainString(t[1].Domain))), dom.td((t[1].Dispositions || []).join(' ')), dom.td(style({ textAlign: 'right' }), '' + t[1].Count), dom.td(style({ textAlign: 'right' }), t[1].SendReport ? '✓' : ''))), isEmpty(evalStats) ? dom.tr(dom.td(attr.colspan('3'), 'No evaluations.')) : [])), dom.br(), dom.br(), dom.h2('Suppressed reporting addresses'), dom.p('In practice, sending a DMARC report to a reporting address can cause DSN to be sent back. Such addresses can be added to a suppression list for a period, to reduce noise in the postmaster mailbox.'), dom.form(async function submit(e) { e.stopPropagation(); e.preventDefault(); await check(fieldset, client.DMARCSuppressAdd(reportingAddress.value, new Date(until.value), comment.value)); @@ -2752,7 +2752,7 @@ const dmarcEvaluationsDomain = async (domain) => { }); return r; }; - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), crumblink('DMARC', '#dmarc'), crumblink('Evaluations', '#dmarc/evaluations'), 'Domain ' + domainString(d)), dom.div(dom.clickbutton('Remove evaluations', async function click(e) { + return dom.div(crumbs(crumblink('Mox Admin', '#'), crumblink('DMARC', '#dmarc'), crumblink('Evaluations', '#dmarc/evaluations'), 'Domain ' + domainString(d)), dom.div(dom.clickbutton('Remove evaluations', async function click(e) { await check(e.target, client.DMARCRemoveEvaluations(domain)); window.location.reload(); // todo: only clear the table? })), dom.br(), dom.p('The evaluations below will be sent in a DMARC aggregate report to the addresses found in the published DMARC DNS record, which is fetched again before sending the report. The fields Interval hours, Addresses and Policy are only filled for the first row and whenever a new value in the published DMARC record is encountered.'), dom.table(dom._class('hover'), dom.thead(dom.tr(dom.th('ID'), dom.th('Evaluated'), dom.th('Optional', attr.title('Some evaluations will not cause a DMARC aggregate report to be sent. But if a report is sent, optional records are included.')), dom.th('Interval hours', attr.title('DMARC policies published by a domain can specify how often they would like to receive reports. The default is 24 hours, but can be as often as each hour. To keep reports comparable between different mail servers that send reports, reports are sent at rounded up intervals of whole hours that can divide a 24 hour day, and are aligned with the start of a day at UTC.')), dom.th('Addresses', attr.title('Addresses that will receive the report. An address can have a maximum report size configured. If there is no address, no report will be sent.')), dom.th('Policy', attr.title('Summary of the policy as encountered in the DMARC DNS record of the domain, and used for evaluation.')), dom.th('IP', attr.title('IP address of delivery attempt that was evaluated, relevant for SPF.')), dom.th('Disposition', attr.title('Our decision to accept/reject this message. It may be different than requested by the published policy. For example, when overriding due to delivery from a mailing list or forwarded address.')), dom.th('Aligned DKIM/SPF', attr.title('Whether DKIM and SPF had an aligned pass, where strict/relaxed alignment means whether the domain of an SPF pass and DKIM pass matches the exact domain (strict) or optionally a subdomain (relaxed). A DMARC pass requires at least one pass.')), dom.th('Envelope to', attr.title('Domain used in SMTP RCPT TO during delivery.')), dom.th('Envelope from', attr.title('Domain used in SMTP MAIL FROM during delivery.')), dom.th('Message from', attr.title('Domain in "From" message header.')), dom.th('DKIM details', attr.title('Results of verifying DKIM-Signature headers in message. Only signatures with matching organizational domain are included, regardless of strict/relaxed DKIM alignment in DMARC policy.')), dom.th('SPF details', attr.title('Results of SPF check used in DMARC evaluation. "mfrom" indicates the "SMTP MAIL FROM" domain was used, "helo" indicates the SMTP EHLO domain was used.')))), dom.tbody((evaluations || []).map(e => { @@ -2799,7 +2799,7 @@ const domainDMARC = async (d) => { client.Domain(d), ]); // todo future: table sorting? period selection (last day, 7 days, 1 month, 1 year, custom period)? collapse rows for a report? show totals per report? a simple bar graph to visualize messages and dmarc/dkim/spf fails? similar for TLSRPT. - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), crumblink('Domain ' + domainString(dnsdomain), '#domains/' + d), 'DMARC aggregate reports'), dom.p('DMARC reports are periodically sent by other mail servers that received an email message with a "From" header with our domain. Domains can have a DMARC DNS record that asks other mail servers to send these aggregate reports for analysis.'), dom.p('Below the DMARC aggregate reports for the past 30 days.'), (reports || []).length === 0 ? dom.div('No DMARC reports for domain.') : + return dom.div(crumbs(crumblink('Mox Admin', '#'), crumblink('Domain ' + domainString(dnsdomain), '#domains/' + d), 'DMARC aggregate reports'), dom.p('DMARC reports are periodically sent by other mail servers that received an email message with a "From" header with our domain. Domains can have a DMARC DNS record that asks other mail servers to send these aggregate reports for analysis.'), dom.p('Below the DMARC aggregate reports for the past 30 days.'), (reports || []).length === 0 ? dom.div('No DMARC reports for domain.') : dom.table(dom._class('hover'), dom.thead(dom.tr(dom.th('ID'), dom.th('Organisation', attr.title('Organization that sent the DMARC report.')), dom.th('Period (UTC)', attr.title('Period this reporting period is about. Mail servers are recommended to stick to whole UTC days.')), dom.th('Policy', attr.title('The DMARC policy that the remote mail server had fetched and applied to the message. A policy that changed during the reporting period may result in unexpected policy evaluations.')), dom.th('Source IP', attr.title('Remote IP address of session at remote mail server.')), dom.th('Messages', attr.title('Total messages that the results apply to.')), dom.th('Result', attr.title('DMARC evaluation result.')), dom.th('ADKIM', attr.title('DKIM alignment. For a pass, one of the DKIM signatures that pass must be strict/relaxed-aligned with the domain, as specified by the policy.')), dom.th('ASPF', attr.title('SPF alignment. For a pass, the SPF policy must pass and be strict/relaxed-aligned with the domain, as specified by the policy.')), dom.th('SMTP to', attr.title('Domain of destination address, as specified during the SMTP session.')), dom.th('SMTP from', attr.title('Domain of originating address, as specified during the SMTP session.')), dom.th('Header from', attr.title('Domain of address in From-header of message.')), dom.th('Auth Results', attr.title('Details of DKIM and/or SPF authentication results. DMARC requires at least one aligned DKIM or SPF pass.')))), dom.tbody((reports || []).map(r => { const m = r.ReportMetadata; let policy = []; @@ -2912,10 +2912,10 @@ const domainDMARCReport = async (d, reportID) => { client.DMARCReportID(d, reportID), client.Domain(d), ]); - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), crumblink('Domain ' + domainString(dnsdomain), '#domains/' + d), crumblink('DMARC aggregate reports', '#domains/' + d + '/dmarc'), 'Report ' + reportID), dom.p('Below is the raw report as received from the remote mail server.'), dom.div(dom._class('literal'), JSON.stringify(report, null, '\t'))); + return dom.div(crumbs(crumblink('Mox Admin', '#'), crumblink('Domain ' + domainString(dnsdomain), '#domains/' + d), crumblink('DMARC aggregate reports', '#domains/' + d + '/dmarc'), 'Report ' + reportID), dom.p('Below is the raw report as received from the remote mail server.'), dom.div(dom._class('literal'), JSON.stringify(report, null, '\t'))); }; const tlsrptIndex = async () => { - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), 'TLSRPT'), dom.ul(dom.li(dom.a(attr.href('#tlsrpt/reports'), 'Reports'), ', incoming TLS reports.'), dom.li(dom.a(attr.href('#tlsrpt/results'), 'Results'), ', for outgoing TLS reports.'))); + return dom.div(crumbs(crumblink('Mox Admin', '#'), 'TLSRPT'), dom.ul(dom.li(dom.a(attr.href('#tlsrpt/reports'), 'Reports'), ', incoming TLS reports.'), dom.li(dom.a(attr.href('#tlsrpt/results'), 'Results'), ', for outgoing TLS reports.'))); }; const tlsrptResults = async () => { const [results, suppressAddresses] = await Promise.all([ @@ -2928,7 +2928,7 @@ const tlsrptResults = async () => { let until; let comment; const nextmonth = new Date(new Date().getTime() + 31 * 24 * 3600 * 1000); - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), crumblink('TLSRPT', '#tlsrpt'), 'Results'), dom.p('Messages are delivered with SMTP with TLS using STARTTLS if supported and/or required by the recipient domain\'s mail server. TLS connections may fail for various reasons, such as mismatching certificate host name, expired certificates or TLS protocol version/cipher suite incompatibilities. Statistics about successful connections and failed connections are tracked. Results can be tracked for recipient domains (for MTA-STS policies), and per MX host (for DANE). A domain/host can publish a TLSRPT DNS record with addresses that should receive TLS reports. Reports are sent every 24 hours. Not all results are enough reason to send a report, but if a report is sent all results are included. By default, reports are only sent if a report contains a connection failure. Sending reports about all-successful connections can be configured. Reports sent to recipient domains include the results for its MX hosts, and reports for an MX host reference the recipient domains.'), dom.table(dom._class('hover'), dom.thead(dom.tr(dom.th('Day (UTC)', attr.title('Day covering these results, a whole day from 00:00 UTC to 24:00 UTC.')), dom.th('Recipient domain', attr.title('Domain of addressee. For delivery to a recipient, the recipient and policy domains will match for reporting on MTA-STS policies, but can also result in reports for hosts from the MX record of the recipient to report on DANE policies.')), dom.th('Policy domain', attr.title('Domain for TLSRPT policy, specifying URIs to which reports should be sent.')), dom.th('Host', attr.title('Whether policy domain is an (MX) host (for DANE), or a recipient domain (for MTA-STS).')), dom.th('Policies', attr.title('Policies found.')), dom.th('Success', attr.title('Total number of successful connections.')), dom.th('Failure', attr.title('Total number of failed connection attempts.')), dom.th('Failure details', attr.title('Total number of details about failures.')), dom.th('Send report', attr.title('Whether the current results may cause a report to be sent. To prevent report loops, reports are not sent for TLS connections used to deliver TLS or DMARC reports. Whether a report is eventually sent depends on more factors, such as whether the policy domain has a TLSRPT policy with reporting addresses, and whether TLS connection failures were registered (depending on configuration).')))), dom.tbody((results || []).sort((a, b) => { + return dom.div(crumbs(crumblink('Mox Admin', '#'), crumblink('TLSRPT', '#tlsrpt'), 'Results'), dom.p('Messages are delivered with SMTP with TLS using STARTTLS if supported and/or required by the recipient domain\'s mail server. TLS connections may fail for various reasons, such as mismatching certificate host name, expired certificates or TLS protocol version/cipher suite incompatibilities. Statistics about successful connections and failed connections are tracked. Results can be tracked for recipient domains (for MTA-STS policies), and per MX host (for DANE). A domain/host can publish a TLSRPT DNS record with addresses that should receive TLS reports. Reports are sent every 24 hours. Not all results are enough reason to send a report, but if a report is sent all results are included. By default, reports are only sent if a report contains a connection failure. Sending reports about all-successful connections can be configured. Reports sent to recipient domains include the results for its MX hosts, and reports for an MX host reference the recipient domains.'), dom.table(dom._class('hover'), dom.thead(dom.tr(dom.th('Day (UTC)', attr.title('Day covering these results, a whole day from 00:00 UTC to 24:00 UTC.')), dom.th('Recipient domain', attr.title('Domain of addressee. For delivery to a recipient, the recipient and policy domains will match for reporting on MTA-STS policies, but can also result in reports for hosts from the MX record of the recipient to report on DANE policies.')), dom.th('Policy domain', attr.title('Domain for TLSRPT policy, specifying URIs to which reports should be sent.')), dom.th('Host', attr.title('Whether policy domain is an (MX) host (for DANE), or a recipient domain (for MTA-STS).')), dom.th('Policies', attr.title('Policies found.')), dom.th('Success', attr.title('Total number of successful connections.')), dom.th('Failure', attr.title('Total number of failed connection attempts.')), dom.th('Failure details', attr.title('Total number of details about failures.')), dom.th('Send report', attr.title('Whether the current results may cause a report to be sent. To prevent report loops, reports are not sent for TLS connections used to deliver TLS or DMARC reports. Whether a report is eventually sent depends on more factors, such as whether the policy domain has a TLSRPT policy with reporting addresses, and whether TLS connection failures were registered (depending on configuration).')))), dom.tbody((results || []).sort((a, b) => { if (a.DayUTC !== b.DayUTC) { return a.DayUTC < b.DayUTC ? -1 : 1; } @@ -2970,7 +2970,7 @@ const tlsrptResultsPolicyDomain = async (isrcptdom, domain) => { const [d, tlsresults] = await client.TLSRPTResultsDomain(isrcptdom, domain); const recordPromise = client.LookupTLSRPTRecord(domain); let recordBox; - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), crumblink('TLSRPT', '#tlsrpt'), crumblink('Results', '#tlsrpt/results'), (isrcptdom ? 'Recipient domain ' : 'Host ') + domainString(d)), dom.div(dom.clickbutton('Remove results', async function click(e) { + const root = dom.div(crumbs(crumblink('Mox Admin', '#'), crumblink('TLSRPT', '#tlsrpt'), crumblink('Results', '#tlsrpt/results'), (isrcptdom ? 'Recipient domain ' : 'Host ') + domainString(d)), dom.div(dom.clickbutton('Remove results', async function click(e) { e.preventDefault(); await check(e.target, client.TLSRPTRemoveResults(isrcptdom, domain, '')); window.location.reload(); // todo: only clear the table? @@ -2999,12 +2999,13 @@ const tlsrptResultsPolicyDomain = async (isrcptdom, domain) => { } dom._kids(recordBox, l); })(); + return root; }; const tlsrptReports = async () => { const end = new Date(); const start = new Date(new Date().getTime() - 30 * 24 * 3600 * 1000); const summaries = await client.TLSRPTSummaries(start, end, ''); - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), crumblink('TLSRPT', '#tlsrpt'), 'Reports'), dom.p('TLSRPT (TLS reporting) is a mechanism to request feedback from other mail servers about TLS connections to your mail server. If is typically used along with MTA-STS and/or DANE to enforce that SMTP connections are protected with TLS. Mail servers implementing TLSRPT will typically send a daily report with both successful and failed connection counts, including details about failures.'), renderTLSRPTSummaries(summaries || [])); + return dom.div(crumbs(crumblink('Mox Admin', '#'), crumblink('TLSRPT', '#tlsrpt'), 'Reports'), dom.p('TLSRPT (TLS reporting) is a mechanism to request feedback from other mail servers about TLS connections to your mail server. If is typically used along with MTA-STS and/or DANE to enforce that SMTP connections are protected with TLS. Mail servers implementing TLSRPT will typically send a daily report with both successful and failed connection counts, including details about failures.'), renderTLSRPTSummaries(summaries || [])); }; const renderTLSRPTSummaries = (summaries) => { return [ @@ -3030,7 +3031,7 @@ const domainTLSRPT = async (d) => { } return s; }; - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), crumblink('TLSRPT', '#tlsrpt'), crumblink('Reports', '#tlsrpt/reports'), 'Domain ' + domainString(dnsdomain)), dom.p('TLSRPT (TLS reporting) is a mechanism to request feedback from other mail servers about TLS connections to your mail server. If is typically used along with MTA-STS and/or DANE to enforce that SMTP connections are protected with TLS. Mail servers implementing TLSRPT will typically send a daily report with both successful and failed connection counts, including details about failures.'), dom.p('Below the TLS reports for the past 30 days.'), (records || []).length === 0 ? dom.div('No TLS reports for domain.') : + return dom.div(crumbs(crumblink('Mox Admin', '#'), crumblink('TLSRPT', '#tlsrpt'), crumblink('Reports', '#tlsrpt/reports'), 'Domain ' + domainString(dnsdomain)), dom.p('TLSRPT (TLS reporting) is a mechanism to request feedback from other mail servers about TLS connections to your mail server. If is typically used along with MTA-STS and/or DANE to enforce that SMTP connections are protected with TLS. Mail servers implementing TLSRPT will typically send a daily report with both successful and failed connection counts, including details about failures.'), dom.p('Below the TLS reports for the past 30 days.'), (records || []).length === 0 ? dom.div('No TLS reports for domain.') : dom.table(dom._class('hover'), dom.thead(dom.tr(dom.th('Report', attr.colspan('3')), dom.th('Policy', attr.colspan('3')), dom.th('Failure Details', attr.colspan('8'))), dom.tr(dom.th('ID'), dom.th('From', attr.title('SMTP mail from from which we received the report.')), dom.th('Period (UTC)', attr.title('Period this reporting period is about. Mail servers are recommended to stick to whole UTC days.')), dom.th('Policy', attr.title('The policy applied, typically STSv1.')), dom.th('Successes', attr.title('Total number of successful TLS connections for policy.')), dom.th('Failures', attr.title('Total number of failed TLS connections for policy.')), dom.th('Result Type', attr.title('Type of failure.')), dom.th('Sending MTA', attr.title('IP of sending MTA.')), dom.th('Receiving MX Host'), dom.th('Receiving MX HELO'), dom.th('Receiving IP'), dom.th('Count', attr.title('Number of TLS connections that failed with these details.')), dom.th('More', attr.title('Optional additional information about the failure.')), dom.th('Code', attr.title('Optional API error code relating to the failure.')))), dom.tbody((records || []).map(record => { const r = record.Report; let nrows = 0; @@ -3080,11 +3081,11 @@ const domainTLSRPTID = async (d, reportID) => { client.TLSReportID(d, reportID), client.ParseDomain(d), ]); - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), crumblink('TLSRPT', '#tlsrpt'), crumblink('Reports', '#tlsrpt/reports'), crumblink('Domain ' + domainString(dnsdomain), '#tlsrpt/reports/' + d + ''), 'Report ' + reportID), dom.p('Below is the raw report as received from the remote mail server.'), dom.div(dom._class('literal'), JSON.stringify(report, null, '\t'))); + return dom.div(crumbs(crumblink('Mox Admin', '#'), crumblink('TLSRPT', '#tlsrpt'), crumblink('Reports', '#tlsrpt/reports'), crumblink('Domain ' + domainString(dnsdomain), '#tlsrpt/reports/' + d + ''), 'Report ' + reportID), dom.p('Below is the raw report as received from the remote mail server.'), dom.div(dom._class('literal'), JSON.stringify(report, null, '\t'))); }; const mtasts = async () => { const policies = await client.MTASTSPolicies(); - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), 'MTA-STS policies'), dom.p("MTA-STS is a mechanism allowing email domains to publish a policy for using SMTP STARTTLS and TLS verification. See ", link('https://www.rfc-editor.org/rfc/rfc8461.html', 'RFC 8461'), '.'), dom.p("The SMTP protocol is unencrypted by default, though the SMTP STARTTLS command is typically used to enable TLS on a connection. However, MTA's using STARTTLS typically do not validate the TLS certificate. An MTA-STS policy can specify that validation of host name, non-expiration and webpki trust is required."), makeMTASTSTable(policies || [])); + return dom.div(crumbs(crumblink('Mox Admin', '#'), 'MTA-STS policies'), dom.p("MTA-STS is a mechanism allowing email domains to publish a policy for using SMTP STARTTLS and TLS verification. See ", link('https://www.rfc-editor.org/rfc/rfc8461.html', 'RFC 8461'), '.'), dom.p("The SMTP protocol is unencrypted by default, though the SMTP STARTTLS command is typically used to enable TLS on a connection. However, MTA's using STARTTLS typically do not validate the TLS certificate. An MTA-STS policy can specify that validation of host name, non-expiration and webpki trust is required."), makeMTASTSTable(policies || [])); }; const formatMTASTSMX = (mx) => { return mx.map(e => { @@ -3131,7 +3132,7 @@ const dnsbl = async () => { const url = (ip) => 'https://multirbl.valli.org/lookup/' + encodeURIComponent(ip) + '.html'; let fieldset; let monitorTextarea; - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), 'DNS blocklist status for IPs'), dom.p('Follow the external links to a third party DNSBL checker to see if the IP is on one of the many blocklist.'), dom.ul(Object.entries(ipZoneResults).sort().map(ipZones => { + return dom.div(crumbs(crumblink('Mox Admin', '#'), 'DNS blocklist status for IPs'), dom.p('Follow the external links to a third party DNSBL checker to see if the IP is on one of the many blocklist.'), dom.ul(Object.entries(ipZoneResults).sort().map(ipZones => { const [ip, zoneResults] = ipZones; return dom.li(link(url(ip), ip), !ipZones.length ? [] : dom.ul(Object.entries(zoneResults).sort().map(zoneResult => dom.li(zoneResult[0] + ': ', zoneResult[1] === 'pass' ? 'pass' : box(red, zoneResult[1]))))); })), !Object.entries(ipZoneResults).length ? box(red, 'No IPs found.') : [], dom.br(), dom.h2('DNSBL zones checked due to being used for incoming deliveries'), (usingZones || []).length === 0 ? @@ -3230,7 +3231,7 @@ const queueList = async () => { window.alert('' + n + ' message(s) updated'); window.location.reload(); // todo: reload less }); - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), 'Queue'), dom.p(dom.a(attr.href('#queue/retired'), 'Retired messages')), dom.h2('Hold rules', attr.title('Messages submitted to the queue that match a hold rule are automatically marked as "on hold", preventing delivery until explicitly taken off hold again.')), dom.form(attr.id('holdRuleForm'), async function submit(e) { + return dom.div(crumbs(crumblink('Mox Admin', '#'), 'Queue'), dom.p(dom.a(attr.href('#queue/retired'), 'Retired messages')), dom.h2('Hold rules', attr.title('Messages submitted to the queue that match a hold rule are automatically marked as "on hold", preventing delivery until explicitly taken off hold again.')), dom.form(attr.id('holdRuleForm'), async function submit(e) { e.preventDefault(); e.stopPropagation(); const pr = { @@ -3406,7 +3407,7 @@ const retiredList = async () => { tbody = ntbody; }; render(); - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), crumblink('Queue', '#queue'), 'Retired messages'), + return dom.div(crumbs(crumblink('Mox Admin', '#'), crumblink('Queue', '#queue'), 'Retired messages'), // Filtering. filterForm = dom.form(attr.id('queuefilter'), // Referenced by input elements in table row. async function submit(e) { @@ -3531,7 +3532,7 @@ const hooksList = async () => { window.alert('' + n + ' hook(s) updated'); window.location.reload(); // todo: reload less }); - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), 'Webhook queue'), dom.p(dom.a(attr.href('#webhookqueue/retired'), 'Retired webhooks')), dom.h2('Webhooks'), dom.table(dom._class('hover'), style({ width: '100%' }), dom.thead(dom.tr(dom.td(attr.colspan('2'), 'Filter'), dom.td(filterSubmitted = dom.input(attr.form('hooksfilter'), style({ width: '7em' }), attr.title('Example: "<-1h" for filtering webhooks submitted more than 1 hour ago.'))), dom.td(), dom.td(), dom.td(), dom.td(filterAccount = dom.input(attr.form('hooksfilter'), style({ width: '8em' }))), dom.td(filterEvent = dom.select(attr.form('hooksfilter'), function change() { + return dom.div(crumbs(crumblink('Mox Admin', '#'), 'Webhook queue'), dom.p(dom.a(attr.href('#webhookqueue/retired'), 'Retired webhooks')), dom.h2('Webhooks'), dom.table(dom._class('hover'), style({ width: '100%' }), dom.thead(dom.tr(dom.td(attr.colspan('2'), 'Filter'), dom.td(filterSubmitted = dom.input(attr.form('hooksfilter'), style({ width: '7em' }), attr.title('Example: "<-1h" for filtering webhooks submitted more than 1 hour ago.'))), dom.td(), dom.td(), dom.td(), dom.td(filterAccount = dom.input(attr.form('hooksfilter'), style({ width: '8em' }))), dom.td(filterEvent = dom.select(attr.form('hooksfilter'), function change() { filterForm.requestSubmit(); }, dom.option(''), // note: outgoing hook events are in ../webhook/webhook.go, ../mox-/config.go ../webadmin/admin.ts and ../webapi/gendoc.sh. keep in sync. @@ -3625,7 +3626,7 @@ const hooksRetiredList = async () => { tbody = ntbody; }; render(); - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), crumblink('Webhook queue', '#webhookqueue'), 'Retired webhooks'), dom.h2('Retired webhooks'), dom.table(dom._class('hover'), style({ width: '100%' }), dom.thead(dom.tr(dom.td('Filter'), dom.td(), dom.td(filterLastActivity = dom.input(attr.form('hooksfilter'), style({ width: '7em' }), attr.title('Example: ">-1h" for filtering last activity for webhooks more than 1 hour ago.'))), dom.td(filterSubmitted = dom.input(attr.form('hooksfilter'), style({ width: '7em' }), attr.title('Example: "<-1h" for filtering webhooks submitted more than 1 hour ago.'))), dom.td(), dom.td(), dom.td(), dom.td(filterAccount = dom.input(attr.form('hooksfilter'), style({ width: '8em' }))), dom.td(filterEvent = dom.select(attr.form('hooksfilter'), function change() { + return dom.div(crumbs(crumblink('Mox Admin', '#'), crumblink('Webhook queue', '#webhookqueue'), 'Retired webhooks'), dom.h2('Retired webhooks'), dom.table(dom._class('hover'), style({ width: '100%' }), dom.thead(dom.tr(dom.td('Filter'), dom.td(), dom.td(filterLastActivity = dom.input(attr.form('hooksfilter'), style({ width: '7em' }), attr.title('Example: ">-1h" for filtering last activity for webhooks more than 1 hour ago.'))), dom.td(filterSubmitted = dom.input(attr.form('hooksfilter'), style({ width: '7em' }), attr.title('Example: "<-1h" for filtering webhooks submitted more than 1 hour ago.'))), dom.td(), dom.td(), dom.td(), dom.td(filterAccount = dom.input(attr.form('hooksfilter'), style({ width: '8em' }))), dom.td(filterEvent = dom.select(attr.form('hooksfilter'), function change() { filterForm.requestSubmit(); }, dom.option(''), // note: outgoing hook events are in ../webhook/webhook.go, ../mox-/config.go ../webadmin/admin.ts and ../webapi/gendoc.sh. keep in sync. @@ -4004,7 +4005,7 @@ const webserver = async () => { }), ]; }; - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), 'Webserver config'), dom.form(fieldset = dom.fieldset(dom.h2('Domain redirects', attr.title('Corresponds with WebDomainRedirects in domains.conf')), dom.p('Incoming requests for these domains are redirected to the target domain, with HTTPS.'), dom.table(dom.thead(dom.tr(dom.th('From'), dom.th('To'), dom.th('Action ', dom.clickbutton('Add', function click() { + return dom.div(crumbs(crumblink('Mox Admin', '#'), 'Webserver config'), dom.form(fieldset = dom.fieldset(dom.h2('Domain redirects', attr.title('Corresponds with WebDomainRedirects in domains.conf')), dom.p('Incoming requests for these domains are redirected to the target domain, with HTTPS.'), dom.table(dom.thead(dom.tr(dom.th('From'), dom.th('To'), dom.th('Action ', dom.clickbutton('Add', function click() { const row = redirectRow([{ ASCII: '', Unicode: '' }, { ASCII: '', Unicode: '' }]); redirectsTbody.appendChild(row.root); noredirect.style.display = redirectRows.length ? 'none' : ''; @@ -4027,96 +4028,101 @@ const init = async () => { const t = h.split('/'); page.classList.add('loading'); try { + let root; if (h == '') { - await index(); + root = await index(); } else if (h === 'config') { - await config(); + root = await config(); } else if (h === 'loglevels') { - await loglevels(); + root = await loglevels(); } else if (h === 'accounts') { - await accounts(); + root = await accounts(); } else if (t[0] === 'accounts' && t.length === 2) { - await account(t[1]); + root = await account(t[1]); } else if (t[0] === 'domains' && t.length === 2) { - await domain(t[1]); + root = await domain(t[1]); } else if (t[0] === 'domains' && t.length === 4 && t[2] === 'alias') { - await domainAlias(t[1], t[3]); + root = await domainAlias(t[1], t[3]); } else if (t[0] === 'domains' && t.length === 3 && t[2] === 'dmarc') { - await domainDMARC(t[1]); + root = await domainDMARC(t[1]); } else if (t[0] === 'domains' && t.length === 4 && t[2] === 'dmarc' && parseInt(t[3])) { - await domainDMARCReport(t[1], parseInt(t[3])); + root = await domainDMARCReport(t[1], parseInt(t[3])); } else if (t[0] === 'domains' && t.length === 3 && t[2] === 'dnscheck') { - await domainDNSCheck(t[1]); + root = await domainDNSCheck(t[1]); } else if (t[0] === 'domains' && t.length === 3 && t[2] === 'dnsrecords') { - await domainDNSRecords(t[1]); + root = await domainDNSRecords(t[1]); } else if (h === 'queue') { - await queueList(); + root = await queueList(); } else if (h === 'queue/retired') { - await retiredList(); + root = await retiredList(); } else if (h === 'webhookqueue') { - await hooksList(); + root = await hooksList(); } else if (h === 'webhookqueue/retired') { - await hooksRetiredList(); + root = await hooksRetiredList(); } else if (h === 'tlsrpt') { - await tlsrptIndex(); + root = await tlsrptIndex(); } else if (h === 'tlsrpt/reports') { - await tlsrptReports(); + root = await tlsrptReports(); } else if (t[0] === 'tlsrpt' && t[1] === 'reports' && t.length === 3) { - await domainTLSRPT(t[2]); + root = await domainTLSRPT(t[2]); } else if (t[0] === 'tlsrpt' && t[1] === 'reports' && t.length === 4 && parseInt(t[3])) { - await domainTLSRPTID(t[2], parseInt(t[3])); + root = await domainTLSRPTID(t[2], parseInt(t[3])); } else if (h === 'tlsrpt/results') { - await tlsrptResults(); + root = await tlsrptResults(); } else if (t[0] == 'tlsrpt' && t[1] == 'results' && (t[2] === 'rcptdom' || t[2] == 'host') && t.length === 4) { - await tlsrptResultsPolicyDomain(t[2] === 'rcptdom', t[3]); + root = await tlsrptResultsPolicyDomain(t[2] === 'rcptdom', t[3]); } else if (h === 'dmarc') { - await dmarcIndex(); + root = await dmarcIndex(); } else if (h === 'dmarc/reports') { - await dmarcReports(); + root = await dmarcReports(); } else if (h === 'dmarc/evaluations') { - await dmarcEvaluations(); + root = await dmarcEvaluations(); } else if (t[0] == 'dmarc' && t[1] == 'evaluations' && t.length === 3) { - await dmarcEvaluationsDomain(t[2]); + root = await dmarcEvaluationsDomain(t[2]); } else if (h === 'mtasts') { - await mtasts(); + root = await mtasts(); } else if (h === 'dnsbl') { - await dnsbl(); + root = await dnsbl(); } else if (h === 'routes') { - await globalRoutes(); + root = await globalRoutes(); } else if (h === 'webserver') { - await webserver(); + root = await webserver(); } else { - dom._kids(page, 'page not found'); + root = dom.div('page not found'); } + if (window.moxBeforeDisplay) { + moxBeforeDisplay(root); + } + dom._kids(page, root); } catch (err) { console.log('error', err); diff --git a/webadmin/admin.ts b/webadmin/admin.ts index d1b30d9cf..c953f4b06 100644 --- a/webadmin/admin.ts +++ b/webadmin/admin.ts @@ -5,6 +5,8 @@ declare let page: HTMLElement declare let moxversion: string declare let moxgoos: string declare let moxgoarch: string +// From customization script. +declare let moxBeforeDisplay: (webmailroot: HTMLElement) => void const login = async (reason: string) => { return new Promise((resolve: (v: string) => void, _) => { @@ -346,7 +348,7 @@ const index = async () => { let recvID: HTMLInputElement let cidElem: HTMLSpanElement - dom._kids(page, + return dom.div( crumbs('Mox Admin'), checkUpdatesEnabled ? [] : dom.p(box(yellow, 'Warning: Checking for updates has not been enabled in mox.conf (CheckUpdates: true).', dom.br(), 'Make sure you stay up to date through another mechanism!', dom.br(), 'You have a responsibility to keep the internet-connected software you run up to date and secure!', dom.br(), 'See ', link('https://updates.xmox.nl/changelog'))), dom.p( @@ -439,7 +441,7 @@ const globalRoutes = async () => { client.Config(), ]) - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), 'Routes', @@ -451,7 +453,7 @@ const globalRoutes = async () => { const config = async () => { const [staticPath, dynamicPath, staticText, dynamicText] = await client.ConfigFiles() - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), 'Config', @@ -473,7 +475,7 @@ const loglevels = async () => { let pkg: HTMLInputElement let level: HTMLSelectElement - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), 'Log levels', @@ -584,7 +586,7 @@ const accounts = async () => { let account: HTMLInputElement let accountModified = false - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), 'Accounts', @@ -803,7 +805,7 @@ const account = async (name: string) => { return v*mult } - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), crumblink('Accounts', '#accounts'), @@ -1219,7 +1221,7 @@ const domain = async (d: string) => { ) } - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), 'Domain ' + domainString(dnsdomain), @@ -1794,7 +1796,7 @@ const domainAlias = async (d: string, aliasLocalpart: string) => { let delFieldset: HTMLFieldSetElement - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), crumblink('Domain ' + domainString(domain.Domain), '#domains/'+d), @@ -1901,7 +1903,7 @@ const domainDNSRecords = async (d: string) => { client.ParseDomain(d), ]) - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), crumblink('Domain ' + domainString(dnsdomain), '#domains/'+d), @@ -2056,7 +2058,7 @@ const domainDNSCheck = async (d: string) => { ), ] - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), crumblink('Domain ' + domainString(dnsdomain), '#domains/'+d), @@ -2082,7 +2084,7 @@ const domainDNSCheck = async (d: string) => { } const dmarcIndex = async () => { - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), 'DMARC', @@ -2103,7 +2105,7 @@ const dmarcReports = async () => { const start = new Date(new Date().getTime() - 30*24*3600*1000) const summaries = await client.DMARCSummaries(start, end, "") - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), crumblink('DMARC', '#dmarc'), @@ -2165,7 +2167,7 @@ const dmarcEvaluations = async () => { const nextmonth = new Date(new Date().getTime()+31*24*3600*1000) - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), crumblink('DMARC', '#dmarc'), @@ -2305,7 +2307,7 @@ const dmarcEvaluationsDomain = async (domain: string) => { return r } - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), crumblink('DMARC', '#dmarc'), @@ -2411,7 +2413,7 @@ const domainDMARC = async (d: string) => { // todo future: table sorting? period selection (last day, 7 days, 1 month, 1 year, custom period)? collapse rows for a report? show totals per report? a simple bar graph to visualize messages and dmarc/dkim/spf fails? similar for TLSRPT. - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), crumblink('Domain ' + domainString(dnsdomain), '#domains/'+d), @@ -2578,7 +2580,7 @@ const domainDMARCReport = async (d: string, reportID: number) => { client.Domain(d), ]) - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), crumblink('Domain ' + domainString(dnsdomain), '#domains/'+d), @@ -2591,7 +2593,7 @@ const domainDMARCReport = async (d: string, reportID: number) => { } const tlsrptIndex = async () => { - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), 'TLSRPT', @@ -2621,7 +2623,7 @@ const tlsrptResults = async () => { let comment: HTMLInputElement const nextmonth = new Date(new Date().getTime()+31*24*3600*1000) - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), crumblink('TLSRPT', '#tlsrpt'), @@ -2758,7 +2760,7 @@ const tlsrptResultsPolicyDomain = async (isrcptdom: boolean, domain: string) => const recordPromise = client.LookupTLSRPTRecord(domain) let recordBox: HTMLElement - dom._kids(page, + const root = dom.div( crumbs( crumblink('Mox Admin', '#'), crumblink('TLSRPT', '#tlsrpt'), @@ -2808,6 +2810,8 @@ const tlsrptResultsPolicyDomain = async (isrcptdom: boolean, domain: string) => } dom._kids(recordBox, l) })() + + return root } const tlsrptReports = async () => { @@ -2815,7 +2819,7 @@ const tlsrptReports = async () => { const start = new Date(new Date().getTime() - 30*24*3600*1000) const summaries = await client.TLSRPTSummaries(start, end, '') - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), crumblink('TLSRPT', '#tlsrpt'), @@ -2872,7 +2876,7 @@ const domainTLSRPT = async (d: string) => { return s } - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), crumblink('TLSRPT', '#tlsrpt'), @@ -2968,7 +2972,7 @@ const domainTLSRPTID = async (d: string, reportID: number) => { client.ParseDomain(d), ]) - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), crumblink('TLSRPT', '#tlsrpt'), @@ -2984,7 +2988,7 @@ const domainTLSRPTID = async (d: string, reportID: number) => { const mtasts = async () => { const policies = await client.MTASTSPolicies() - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), 'MTA-STS policies', @@ -3056,7 +3060,7 @@ const dnsbl = async () => { let fieldset: HTMLFieldSetElement let monitorTextarea: HTMLTextAreaElement - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), 'DNS blocklist status for IPs', @@ -3256,7 +3260,7 @@ const queueList = async () => { window.location.reload() // todo: reload less }) - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), 'Queue', @@ -3696,7 +3700,7 @@ const retiredList = async () => { } render() - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), crumblink('Queue', '#queue'), @@ -3978,7 +3982,7 @@ const hooksList = async () => { window.location.reload() // todo: reload less }) - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), 'Webhook queue', @@ -4266,7 +4270,7 @@ const hooksRetiredList = async () => { } render() - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), crumblink('Webhook queue', '#webhookqueue'), @@ -5039,7 +5043,7 @@ const webserver = async () => { ] } - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), 'Webserver config', @@ -5121,67 +5125,72 @@ const init = async () => { const t = h.split('/') page.classList.add('loading') try { + let root: HTMLElement if (h == '') { - await index() + root = await index() } else if (h === 'config') { - await config() + root = await config() } else if (h === 'loglevels') { - await loglevels() + root = await loglevels() } else if (h === 'accounts') { - await accounts() + root = await accounts() } else if (t[0] === 'accounts' && t.length === 2) { - await account(t[1]) + root = await account(t[1]) } else if (t[0] === 'domains' && t.length === 2) { - await domain(t[1]) + root = await domain(t[1]) } else if (t[0] === 'domains' && t.length === 4 && t[2] === 'alias') { - await domainAlias(t[1], t[3]) + root = await domainAlias(t[1], t[3]) } else if (t[0] === 'domains' && t.length === 3 && t[2] === 'dmarc') { - await domainDMARC(t[1]) + root = await domainDMARC(t[1]) } else if (t[0] === 'domains' && t.length === 4 && t[2] === 'dmarc' && parseInt(t[3])) { - await domainDMARCReport(t[1], parseInt(t[3])) + root = await domainDMARCReport(t[1], parseInt(t[3])) } else if (t[0] === 'domains' && t.length === 3 && t[2] === 'dnscheck') { - await domainDNSCheck(t[1]) + root = await domainDNSCheck(t[1]) } else if (t[0] === 'domains' && t.length === 3 && t[2] === 'dnsrecords') { - await domainDNSRecords(t[1]) + root = await domainDNSRecords(t[1]) } else if (h === 'queue') { - await queueList() + root = await queueList() } else if (h === 'queue/retired') { - await retiredList() + root = await retiredList() } else if (h === 'webhookqueue') { - await hooksList() + root = await hooksList() } else if (h === 'webhookqueue/retired') { - await hooksRetiredList() + root = await hooksRetiredList() } else if (h === 'tlsrpt') { - await tlsrptIndex() + root = await tlsrptIndex() } else if (h === 'tlsrpt/reports') { - await tlsrptReports() + root = await tlsrptReports() } else if (t[0] === 'tlsrpt' && t[1] === 'reports' && t.length === 3) { - await domainTLSRPT(t[2]) + root = await domainTLSRPT(t[2]) } else if (t[0] === 'tlsrpt' && t[1] === 'reports' && t.length === 4 && parseInt(t[3])) { - await domainTLSRPTID(t[2], parseInt(t[3])) + root = await domainTLSRPTID(t[2], parseInt(t[3])) } else if (h === 'tlsrpt/results') { - await tlsrptResults() + root = await tlsrptResults() } else if (t[0] == 'tlsrpt' && t[1] == 'results' && (t[2] === 'rcptdom' || t[2] == 'host') && t.length === 4) { - await tlsrptResultsPolicyDomain(t[2] === 'rcptdom', t[3]) + root = await tlsrptResultsPolicyDomain(t[2] === 'rcptdom', t[3]) } else if (h === 'dmarc') { - await dmarcIndex() + root = await dmarcIndex() } else if (h === 'dmarc/reports') { - await dmarcReports() + root = await dmarcReports() } else if (h === 'dmarc/evaluations') { - await dmarcEvaluations() + root = await dmarcEvaluations() } else if (t[0] == 'dmarc' && t[1] == 'evaluations' && t.length === 3) { - await dmarcEvaluationsDomain(t[2]) + root = await dmarcEvaluationsDomain(t[2]) } else if (h === 'mtasts') { - await mtasts() + root = await mtasts() } else if (h === 'dnsbl') { - await dnsbl() + root = await dnsbl() } else if (h === 'routes') { - await globalRoutes() + root = await globalRoutes() } else if (h === 'webserver') { - await webserver() + root = await webserver() } else { - dom._kids(page, 'page not found') + root = dom.div('page not found') + } + if ((window as any).moxBeforeDisplay) { + moxBeforeDisplay(root) } + dom._kids(page, root) } catch (err) { console.log('error', err) window.alert('Error: ' + errmsg(err)) diff --git a/webmail/lib.ts b/webmail/lib.ts index b05bd433d..c11345163 100644 --- a/webmail/lib.ts +++ b/webmail/lib.ts @@ -9,11 +9,11 @@ // We keep the default/regular styles and dark-mode styles in separate stylesheets. const cssStyle = dom.style(attr.type('text/css')) -document.head.appendChild(cssStyle) +document.head.prepend(cssStyle) const styleSheet = cssStyle.sheet! const cssStyleDark = dom.style(attr.type('text/css')) -document.head.appendChild(cssStyleDark) +document.head.prepend(cssStyleDark) const styleSheetDark = cssStyleDark.sheet! styleSheetDark.insertRule('@media (prefers-color-scheme: dark) {}') const darkModeRule = styleSheetDark.cssRules[0] as CSSMediaRule @@ -42,8 +42,11 @@ const ensureCSS = (selector: string, styles: { [prop: string]: string | number | let darkst: CSSStyleDeclaration | undefined for (let [k, v] of Object.entries(styles)) { // We've kept the camel-case in our code which we had from when we did "st[prop] = - // value". It is more convenient as object keys. So convert to kebab-case. - k = k.replace(/[A-Z]/g, s => '-'+s.toLowerCase()) + // value". It is more convenient as object keys. So convert to kebab-case, but only + // if this is not a css property. + if (!k.startsWith('--')) { + k = k.replace(/[A-Z]/g, s => '-'+s.toLowerCase()) + } if (Array.isArray(v)) { if (v.length !== 2) { throw new Error('2 elements required for light/dark mode style, got '+v.length) @@ -70,64 +73,132 @@ const css = (className: string, styles: { [prop: string]: string | number | stri // todo: reduce number of colors. hopefully we can derive some colors from a few base colors (making them brighter/darker, or shifting hue, etc). then make them configurable through settings. // todo: add the standard padding and border-radius, perhaps more. -// todo: could make some of these {prop: value} objects and pass them directly to css() +// We define css variables, making them easy to override. +ensureCSS(':root', { + '--color': ['black', '#ddd'], + '--colorMild': ['#555', '#bbb'], + '--colorMilder': ['#666', '#aaa'], + '--backgroundColor': ['white', '#222'], + '--backgroundColorMild': ['#f8f8f8', '#080808'], + '--backgroundColorMilder': ['#999', '#777'], + '--borderColor': ['#ccc', '#333'], + '--mailboxesTopBackgroundColor': ['#fdfdf1', '#1a1200'], + '--msglistBackgroundColor': ['#f5ffff', '#04130d'], + '--boxShadow': ['0 0 20px rgba(0, 0, 0, 0.1)', '0px 0px 20px #000'], + + '--buttonBackground': ['#eee', '#222'], + '--buttonBorderColor': ['#888', '#666'], + '--buttonHoverBackground': ['#ddd', '#333'], + + '--overlayOpaqueBackgroundColor': ['#eee', '#011'], + '--overlayBackgroundColor': ['rgba(0, 0, 0, 0.2)', 'rgba(0, 0, 0, 0.5)'], + + '--popupColor': ['black', 'white'], + '--popupBackgroundColor': ['white', '#313233'], + '--popupBorderColor': ['#ccc', '#555'], + + '--highlightBackground': ['gold', '#a70167'], + '--highlightBorderColor': ['#8c7600', '#fd1fa7'], + '--highlightBackgroundHover': ['#ffbd21', '#710447'], + + '--mailboxActiveBackground': ['linear-gradient(135deg, #ffc7ab 0%, #ffdeab 100%)', 'linear-gradient(135deg, #b63d00 0%, #8c5a0d 100%)'], + '--mailboxHoverBackgroundColor': ['#eee', '#421f15'], + + '--msgItemActiveBackground': ['linear-gradient(135deg, #8bc8ff 0%, #8ee5ff 100%)', 'linear-gradient(135deg, #045cac 0%, #027ba0 100%)'], + '--msgItemHoverBackgroundColor': ['#eee', '#073348'], + '--msgItemFocusBorderColor': ['#2685ff', '#2685ff'], + + '--buttonTristateOnBackground': ['#c4ffa9', '#277e00'], + '--buttonTristateOffBackground': ['#ffb192', '#bf410f'], + + '--warningBackgroundColor': ['#ffca91', '#a85700'], + + '--successBackground': ['#d2f791', '#1fa204'], + '--emphasisBackground': ['#666', '#aaa'], + + // For authentication/security results. + '--underlineGreen': '#50c40f', + '--underlineRed': '#e15d1c', + '--underlineBlue': '#09f', + '--underlineGrey': '#888', + + '--quoted1Color': ['#03828f', '#71f2ff'], // red + '--quoted2Color': ['#c7445c', '#ec4c4c'], // green + '--quoted3Color': ['#417c10', '#73e614'], // blue + + '--scriptSwitchUnderlineColor': ['#dca053', '#e88f1e'], + + '--linkColor': ['#096bc2', '#63b6ff'], + '--linkVisitedColor': ['#0704c1', '#c763ff'], +}) + +// Typed way to reference a css variables. Kept from before used variables. const styles = { - color: ['black', '#ddd'], - colorMild: ['#555', '#bbb'], - colorMilder: ['#666', '#aaa'], - backgroundColor: ['white', '#222'], - backgroundColorMild: ['#f8f8f8', '#080808'], - backgroundColorMilder: ['#999', '#777'], - borderColor: ['#ccc', '#333'], - mailboxesTopBackgroundColor: ['#fdfdf1', 'rgb(26, 18, 0)'], - msglistBackgroundColor: ['#f5ffff', 'rgb(4, 19, 13)'], - boxShadow: ['0 0 20px rgba(0, 0, 0, 0.1)', '0px 0px 20px #000'], + color: 'var(--color)', + colorMild: 'var(--colorMild)', + colorMilder: 'var(--colorMilder)', + backgroundColor: 'var(--backgroundColor)', + backgroundColorMild: 'var(--backgroundColorMild)', + backgroundColorMilder: 'var(--backgroundColorMilder)', + borderColor: 'var(--borderColor)', + mailboxesTopBackgroundColor: 'var(--mailboxesTopBackgroundColor)', + msglistBackgroundColor: 'var(--msglistBackgroundColor)', + boxShadow: 'var(--boxShadow)', - buttonBackground: ['#eee', '#222'], - buttonBorderColor: ['#888', '#666'], - buttonHoverBackground: ['#ddd', '#333'], + buttonBackground: 'var(--buttonBackground)', + buttonBorderColor: 'var(--buttonBorderColor)', + buttonHoverBackground: 'var(--buttonHoverBackground)', - overlayOpaqueBackgroundColor: ['#eee', '#011'], - overlayBackgroundColor: ['rgba(0, 0, 0, 0.2)', 'rgba(0, 0, 0, 0.5)'], + overlayOpaqueBackgroundColor: 'var(--overlayOpaqueBackgroundColor)', + overlayBackgroundColor: 'var(--overlayBackgroundColor)', - popupColor: ['black', 'white'], - popupBackgroundColor: ['white', 'rgb(49, 50, 51)'], - popupBorderColor: ['#ccc', '#555'], + popupColor: 'var(--popupColor)', + popupBackgroundColor: 'var(--popupBackgroundColor)', + popupBorderColor: 'var(--popupBorderColor)', - highlightBackground: ['gold', '#a70167'], - highlightBorderColor: ['#8c7600', 'rgb(253, 31, 167)'], - highlightBackgroundHover: ['#ffbd21', 'rgb(113, 4, 71)'], + highlightBackground: 'var(--highlightBackground)', + highlightBorderColor: 'var(--highlightBorderColor)', + highlightBackgroundHover: 'var(--highlightBackgroundHover)', - mailboxActiveBackground: ['linear-gradient(135deg, #ffc7ab 0%, #ffdeab 100%)', 'linear-gradient(135deg, rgb(182, 61, 0) 0%, rgb(140, 90, 13) 100%)'], - mailboxHoverBackgroundColor: ['#eee', 'rgb(66, 31, 21)'], + mailboxActiveBackground: 'var(--mailboxActiveBackground)', + mailboxHoverBackgroundColor: 'var(--mailboxHoverBackgroundColor)', - msgItemActiveBackground: ['linear-gradient(135deg, #8bc8ff 0%, #8ee5ff 100%)', 'linear-gradient(135deg, rgb(4, 92, 172) 0%, rgb(2, 123, 160) 100%)'], - msgItemHoverBackgroundColor: ['#eee', 'rgb(7, 51, 72)'], - msgItemFocusBorderColor: ['#2685ff', '#2685ff'], + msgItemActiveBackground: 'var(--msgItemActiveBackground)', + msgItemHoverBackgroundColor: 'var(--msgItemHoverBackgroundColor)', + msgItemFocusBorderColor: 'var(--msgItemFocusBorderColor)', - buttonTristateOnBackground: ['#c4ffa9', 'rgb(39, 126, 0)'], - buttonTristateOffBackground: ['#ffb192', 'rgb(191, 65, 15)'], + buttonTristateOnBackground: 'var(--buttonTristateOnBackground)', + buttonTristateOffBackground: 'var(--buttonTristateOffBackground)', - warningBackgroundColor: ['#ffca91', 'rgb(168, 87, 0)'], + warningBackgroundColor: 'var(--warningBackgroundColor)', - successBackground: ['#d2f791', '#1fa204'], - emphasisBackground: ['#666', '#aaa'], + successBackground: 'var(--successBackground)', + emphasisBackground: 'var(--emphasisBackground)', // For authentication/security results. - underlineGreen: '#50c40f', - underlineRed: '#e15d1c', - underlineBlue: '#09f', - underlineGrey: '#888', + underlineGreen: 'var(--underlineGreen)', + underlineRed: 'var(--underlineRed)', + underlineBlue: 'var(--underlineBlue)', + underlineGrey: 'var(--underlineGrey)', + + quoted1Color: 'var(--quoted1Color)', + quoted2Color: 'var(--quoted2Color)', + quoted3Color: 'var(--quoted3Color)', + + scriptSwitchUnderlineColor: 'var(--scriptSwitchUnderlineColor)', + + linkColor: 'var(--linkColor)', + linkVisitedColor: 'var(--linkVisitedColor)', } const styleClasses = { // For quoted text, with multiple levels of indentations. quoted: [ - css('quoted1', {color: ['#03828f', '#71f2ff']}), // red - css('quoted2', {color: ['#c7445c', 'rgb(236, 76, 76)']}), // green - css('quoted3', {color: ['#417c10', 'rgb(115, 230, 20)']}), // blue + css('quoted1', {color: styles.quoted1Color}), + css('quoted2', {color: styles.quoted2Color}), + css('quoted3', {color: styles.quoted3Color}), ], // When text switches between unicode scripts. - scriptswitch: css('scriptswitch', {textDecoration: 'underline 2px', textDecorationColor: ['#dca053', 'rgb(232, 143, 30)']}), + scriptswitch: css('scriptswitch', {textDecoration: 'underline 2px', textDecorationColor: styles.scriptSwitchUnderlineColor}), textMild: css('textMild', {color: styles.colorMild}), // For keywords (also known as flags/labels/tags) on messages. keyword: css('keyword', {padding: '0 .15em', borderRadius: '.15em', fontWeight: 'normal', fontSize: '.9em', margin: '0 .15em', whiteSpace: 'nowrap', background: styles.highlightBackground, color: styles.color, border: '1px solid', borderColor: styles.highlightBorderColor}), @@ -138,15 +209,15 @@ ensureCSS('.msgHeaders td', {wordBreak: 'break-word'}) // Prevent horizontal scr ensureCSS('.keyword.keywordCollapsed', {opacity: .75}), // Generic styling. +ensureCSS('html', {backgroundColor: 'var(--backgroundColor)', color: 'var(--color)'}) ensureCSS('*', {fontSize: 'inherit', fontFamily: "'ubuntu', 'lato', sans-serif", margin: 0, padding: 0, boxSizing: 'border-box'}) ensureCSS('.mono, .mono *', {fontFamily: "'ubuntu mono', monospace"}) ensureCSS('table td, table th', {padding: '.15em .25em'}) ensureCSS('.pad', {padding: '.5em'}) ensureCSS('iframe', {border: 0}) ensureCSS('img, embed, video, iframe', {backgroundColor: 'white', color: 'black'}) -ensureCSS(':root', {backgroundColor: styles.backgroundColor, color: styles.color}) -ensureCSS('a', {color: ['rgb(9, 107, 194)', 'rgb(99, 182, 255)']}) -ensureCSS('a:visited', {color: ['rgb(7, 4, 193)', 'rgb(199, 99, 255)']}) +ensureCSS('a', {color: styles.linkColor}) +ensureCSS('a:visited', {color: styles.linkVisitedColor}) // For message view with multiple inline elements (often a single text and multiple messages). ensureCSS('.textmulti > *:nth-child(even)', {backgroundColor: ['#f4f4f4', '#141414']}) diff --git a/webmail/msg.html b/webmail/msg.html index d50aa7442..2fb6f9511 100644 --- a/webmail/msg.html +++ b/webmail/msg.html @@ -4,10 +4,17 @@ Message +
Loading...
+ + diff --git a/webmail/msg.js b/webmail/msg.js index 076e4e250..071949f41 100644 --- a/webmail/msg.js +++ b/webmail/msg.js @@ -1055,10 +1055,10 @@ var api; // instances of a class. // We keep the default/regular styles and dark-mode styles in separate stylesheets. const cssStyle = dom.style(attr.type('text/css')); -document.head.appendChild(cssStyle); +document.head.prepend(cssStyle); const styleSheet = cssStyle.sheet; const cssStyleDark = dom.style(attr.type('text/css')); -document.head.appendChild(cssStyleDark); +document.head.prepend(cssStyleDark); const styleSheetDark = cssStyleDark.sheet; styleSheetDark.insertRule('@media (prefers-color-scheme: dark) {}'); const darkModeRule = styleSheetDark.cssRules[0]; @@ -1085,8 +1085,11 @@ const ensureCSS = (selector, styles, important) => { let darkst; for (let [k, v] of Object.entries(styles)) { // We've kept the camel-case in our code which we had from when we did "st[prop] = - // value". It is more convenient as object keys. So convert to kebab-case. - k = k.replace(/[A-Z]/g, s => '-' + s.toLowerCase()); + // value". It is more convenient as object keys. So convert to kebab-case, but only + // if this is not a css property. + if (!k.startsWith('--')) { + k = k.replace(/[A-Z]/g, s => '-' + s.toLowerCase()); + } if (Array.isArray(v)) { if (v.length !== 2) { throw new Error('2 elements required for light/dark mode style, got ' + v.length); @@ -1112,54 +1115,105 @@ const css = (className, styles, important) => { }; // todo: reduce number of colors. hopefully we can derive some colors from a few base colors (making them brighter/darker, or shifting hue, etc). then make them configurable through settings. // todo: add the standard padding and border-radius, perhaps more. -// todo: could make some of these {prop: value} objects and pass them directly to css() +// We define css variables, making them easy to override. +ensureCSS(':root', { + '--color': ['black', '#ddd'], + '--colorMild': ['#555', '#bbb'], + '--colorMilder': ['#666', '#aaa'], + '--backgroundColor': ['white', '#222'], + '--backgroundColorMild': ['#f8f8f8', '#080808'], + '--backgroundColorMilder': ['#999', '#777'], + '--borderColor': ['#ccc', '#333'], + '--mailboxesTopBackgroundColor': ['#fdfdf1', '#1a1200'], + '--msglistBackgroundColor': ['#f5ffff', '#04130d'], + '--boxShadow': ['0 0 20px rgba(0, 0, 0, 0.1)', '0px 0px 20px #000'], + '--buttonBackground': ['#eee', '#222'], + '--buttonBorderColor': ['#888', '#666'], + '--buttonHoverBackground': ['#ddd', '#333'], + '--overlayOpaqueBackgroundColor': ['#eee', '#011'], + '--overlayBackgroundColor': ['rgba(0, 0, 0, 0.2)', 'rgba(0, 0, 0, 0.5)'], + '--popupColor': ['black', 'white'], + '--popupBackgroundColor': ['white', '#313233'], + '--popupBorderColor': ['#ccc', '#555'], + '--highlightBackground': ['gold', '#a70167'], + '--highlightBorderColor': ['#8c7600', '#fd1fa7'], + '--highlightBackgroundHover': ['#ffbd21', '#710447'], + '--mailboxActiveBackground': ['linear-gradient(135deg, #ffc7ab 0%, #ffdeab 100%)', 'linear-gradient(135deg, #b63d00 0%, #8c5a0d 100%)'], + '--mailboxHoverBackgroundColor': ['#eee', '#421f15'], + '--msgItemActiveBackground': ['linear-gradient(135deg, #8bc8ff 0%, #8ee5ff 100%)', 'linear-gradient(135deg, #045cac 0%, #027ba0 100%)'], + '--msgItemHoverBackgroundColor': ['#eee', '#073348'], + '--msgItemFocusBorderColor': ['#2685ff', '#2685ff'], + '--buttonTristateOnBackground': ['#c4ffa9', '#277e00'], + '--buttonTristateOffBackground': ['#ffb192', '#bf410f'], + '--warningBackgroundColor': ['#ffca91', '#a85700'], + '--successBackground': ['#d2f791', '#1fa204'], + '--emphasisBackground': ['#666', '#aaa'], + // For authentication/security results. + '--underlineGreen': '#50c40f', + '--underlineRed': '#e15d1c', + '--underlineBlue': '#09f', + '--underlineGrey': '#888', + '--quoted1Color': ['#03828f', '#71f2ff'], + '--quoted2Color': ['#c7445c', '#ec4c4c'], + '--quoted3Color': ['#417c10', '#73e614'], + '--scriptSwitchUnderlineColor': ['#dca053', '#e88f1e'], + '--linkColor': ['#096bc2', '#63b6ff'], + '--linkVisitedColor': ['#0704c1', '#c763ff'], +}); +// Typed way to reference a css variables. Kept from before used variables. const styles = { - color: ['black', '#ddd'], - colorMild: ['#555', '#bbb'], - colorMilder: ['#666', '#aaa'], - backgroundColor: ['white', '#222'], - backgroundColorMild: ['#f8f8f8', '#080808'], - backgroundColorMilder: ['#999', '#777'], - borderColor: ['#ccc', '#333'], - mailboxesTopBackgroundColor: ['#fdfdf1', 'rgb(26, 18, 0)'], - msglistBackgroundColor: ['#f5ffff', 'rgb(4, 19, 13)'], - boxShadow: ['0 0 20px rgba(0, 0, 0, 0.1)', '0px 0px 20px #000'], - buttonBackground: ['#eee', '#222'], - buttonBorderColor: ['#888', '#666'], - buttonHoverBackground: ['#ddd', '#333'], - overlayOpaqueBackgroundColor: ['#eee', '#011'], - overlayBackgroundColor: ['rgba(0, 0, 0, 0.2)', 'rgba(0, 0, 0, 0.5)'], - popupColor: ['black', 'white'], - popupBackgroundColor: ['white', 'rgb(49, 50, 51)'], - popupBorderColor: ['#ccc', '#555'], - highlightBackground: ['gold', '#a70167'], - highlightBorderColor: ['#8c7600', 'rgb(253, 31, 167)'], - highlightBackgroundHover: ['#ffbd21', 'rgb(113, 4, 71)'], - mailboxActiveBackground: ['linear-gradient(135deg, #ffc7ab 0%, #ffdeab 100%)', 'linear-gradient(135deg, rgb(182, 61, 0) 0%, rgb(140, 90, 13) 100%)'], - mailboxHoverBackgroundColor: ['#eee', 'rgb(66, 31, 21)'], - msgItemActiveBackground: ['linear-gradient(135deg, #8bc8ff 0%, #8ee5ff 100%)', 'linear-gradient(135deg, rgb(4, 92, 172) 0%, rgb(2, 123, 160) 100%)'], - msgItemHoverBackgroundColor: ['#eee', 'rgb(7, 51, 72)'], - msgItemFocusBorderColor: ['#2685ff', '#2685ff'], - buttonTristateOnBackground: ['#c4ffa9', 'rgb(39, 126, 0)'], - buttonTristateOffBackground: ['#ffb192', 'rgb(191, 65, 15)'], - warningBackgroundColor: ['#ffca91', 'rgb(168, 87, 0)'], - successBackground: ['#d2f791', '#1fa204'], - emphasisBackground: ['#666', '#aaa'], + color: 'var(--color)', + colorMild: 'var(--colorMild)', + colorMilder: 'var(--colorMilder)', + backgroundColor: 'var(--backgroundColor)', + backgroundColorMild: 'var(--backgroundColorMild)', + backgroundColorMilder: 'var(--backgroundColorMilder)', + borderColor: 'var(--borderColor)', + mailboxesTopBackgroundColor: 'var(--mailboxesTopBackgroundColor)', + msglistBackgroundColor: 'var(--msglistBackgroundColor)', + boxShadow: 'var(--boxShadow)', + buttonBackground: 'var(--buttonBackground)', + buttonBorderColor: 'var(--buttonBorderColor)', + buttonHoverBackground: 'var(--buttonHoverBackground)', + overlayOpaqueBackgroundColor: 'var(--overlayOpaqueBackgroundColor)', + overlayBackgroundColor: 'var(--overlayBackgroundColor)', + popupColor: 'var(--popupColor)', + popupBackgroundColor: 'var(--popupBackgroundColor)', + popupBorderColor: 'var(--popupBorderColor)', + highlightBackground: 'var(--highlightBackground)', + highlightBorderColor: 'var(--highlightBorderColor)', + highlightBackgroundHover: 'var(--highlightBackgroundHover)', + mailboxActiveBackground: 'var(--mailboxActiveBackground)', + mailboxHoverBackgroundColor: 'var(--mailboxHoverBackgroundColor)', + msgItemActiveBackground: 'var(--msgItemActiveBackground)', + msgItemHoverBackgroundColor: 'var(--msgItemHoverBackgroundColor)', + msgItemFocusBorderColor: 'var(--msgItemFocusBorderColor)', + buttonTristateOnBackground: 'var(--buttonTristateOnBackground)', + buttonTristateOffBackground: 'var(--buttonTristateOffBackground)', + warningBackgroundColor: 'var(--warningBackgroundColor)', + successBackground: 'var(--successBackground)', + emphasisBackground: 'var(--emphasisBackground)', // For authentication/security results. - underlineGreen: '#50c40f', - underlineRed: '#e15d1c', - underlineBlue: '#09f', - underlineGrey: '#888', + underlineGreen: 'var(--underlineGreen)', + underlineRed: 'var(--underlineRed)', + underlineBlue: 'var(--underlineBlue)', + underlineGrey: 'var(--underlineGrey)', + quoted1Color: 'var(--quoted1Color)', + quoted2Color: 'var(--quoted2Color)', + quoted3Color: 'var(--quoted3Color)', + scriptSwitchUnderlineColor: 'var(--scriptSwitchUnderlineColor)', + linkColor: 'var(--linkColor)', + linkVisitedColor: 'var(--linkVisitedColor)', }; const styleClasses = { // For quoted text, with multiple levels of indentations. quoted: [ - css('quoted1', { color: ['#03828f', '#71f2ff'] }), - css('quoted2', { color: ['#c7445c', 'rgb(236, 76, 76)'] }), - css('quoted3', { color: ['#417c10', 'rgb(115, 230, 20)'] }), // blue + css('quoted1', { color: styles.quoted1Color }), + css('quoted2', { color: styles.quoted2Color }), + css('quoted3', { color: styles.quoted3Color }), ], // When text switches between unicode scripts. - scriptswitch: css('scriptswitch', { textDecoration: 'underline 2px', textDecorationColor: ['#dca053', 'rgb(232, 143, 30)'] }), + scriptswitch: css('scriptswitch', { textDecoration: 'underline 2px', textDecorationColor: styles.scriptSwitchUnderlineColor }), textMild: css('textMild', { color: styles.colorMild }), // For keywords (also known as flags/labels/tags) on messages. keyword: css('keyword', { padding: '0 .15em', borderRadius: '.15em', fontWeight: 'normal', fontSize: '.9em', margin: '0 .15em', whiteSpace: 'nowrap', background: styles.highlightBackground, color: styles.color, border: '1px solid', borderColor: styles.highlightBorderColor }), @@ -1168,15 +1222,15 @@ const styleClasses = { ensureCSS('.msgHeaders td', { wordBreak: 'break-word' }); // Prevent horizontal scroll bar for long header values. ensureCSS('.keyword.keywordCollapsed', { opacity: .75 }), // Generic styling. - ensureCSS('*', { fontSize: 'inherit', fontFamily: "'ubuntu', 'lato', sans-serif", margin: 0, padding: 0, boxSizing: 'border-box' }); + ensureCSS('html', { backgroundColor: 'var(--backgroundColor)', color: 'var(--color)' }); +ensureCSS('*', { fontSize: 'inherit', fontFamily: "'ubuntu', 'lato', sans-serif", margin: 0, padding: 0, boxSizing: 'border-box' }); ensureCSS('.mono, .mono *', { fontFamily: "'ubuntu mono', monospace" }); ensureCSS('table td, table th', { padding: '.15em .25em' }); ensureCSS('.pad', { padding: '.5em' }); ensureCSS('iframe', { border: 0 }); ensureCSS('img, embed, video, iframe', { backgroundColor: 'white', color: 'black' }); -ensureCSS(':root', { backgroundColor: styles.backgroundColor, color: styles.color }); -ensureCSS('a', { color: ['rgb(9, 107, 194)', 'rgb(99, 182, 255)'] }); -ensureCSS('a:visited', { color: ['rgb(7, 4, 193)', 'rgb(199, 99, 255)'] }); +ensureCSS('a', { color: styles.linkColor }); +ensureCSS('a:visited', { color: styles.linkVisitedColor }); // For message view with multiple inline elements (often a single text and multiple messages). ensureCSS('.textmulti > *:nth-child(even)', { backgroundColor: ['#f4f4f4', '#141414'] }); ensureCSS('.textmulti > *', { padding: '2ex .5em', margin: '-.5em' /* compensate pad */ }); @@ -1417,13 +1471,17 @@ const init = () => { iframepath += '?sameorigin=true'; let iframe; const page = document.getElementById('page'); - dom._kids(page, dom.div(css('msgMeta', { backgroundColor: styles.backgroundColorMild, borderBottom: '1px solid', borderBottomColor: styles.borderColor }), msgheaderview, msgattachmentview), iframe = dom.iframe(attr.title('Message body.'), attr.src(iframepath), css('msgIframe', { width: '100%', height: '100%' }), function load() { + const root = dom.div(dom.div(css('msgMeta', { backgroundColor: styles.backgroundColorMild, borderBottom: '1px solid', borderBottomColor: styles.borderColor }), msgheaderview, msgattachmentview), iframe = dom.iframe(attr.title('Message body.'), attr.src(iframepath), css('msgIframe', { width: '100%', height: '100%' }), function load() { // Note: we load the iframe content specifically in a way that fires the load event only when the content is fully rendered. iframe.style.height = iframe.contentDocument.documentElement.scrollHeight + 'px'; if (window.location.hash === '#print') { window.print(); } })); + if (typeof moxBeforeDisplay !== 'undefined') { + moxBeforeDisplay(root); + } + dom._kids(page, root); }; try { init(); diff --git a/webmail/msg.ts b/webmail/msg.ts index b690969fa..46454bb7c 100644 --- a/webmail/msg.ts +++ b/webmail/msg.ts @@ -2,6 +2,8 @@ // Loaded from synchronous javascript. declare let messageItem: api.MessageItem +// From customization script. +declare let moxBeforeDisplay: (root: HTMLElement) => void const init = () => { const mi = api.parser.MessageItem(messageItem) @@ -40,7 +42,7 @@ const init = () => { let iframe: HTMLIFrameElement const page = document.getElementById('page')! - dom._kids(page, + const root = dom.div( dom.div( css('msgMeta', {backgroundColor: styles.backgroundColorMild, borderBottom: '1px solid', borderBottomColor: styles.borderColor}), msgheaderview, @@ -59,6 +61,10 @@ const init = () => { }, ) ) + if (typeof moxBeforeDisplay !== 'undefined') { + moxBeforeDisplay(root) + } + dom._kids(page, root) } try { diff --git a/webmail/text.html b/webmail/text.html index 5121d3737..2129b09c2 100644 --- a/webmail/text.html +++ b/webmail/text.html @@ -3,10 +3,17 @@ +
Loading...
+ + diff --git a/webmail/text.js b/webmail/text.js index 73496da68..2bc056956 100644 --- a/webmail/text.js +++ b/webmail/text.js @@ -1055,10 +1055,10 @@ var api; // instances of a class. // We keep the default/regular styles and dark-mode styles in separate stylesheets. const cssStyle = dom.style(attr.type('text/css')); -document.head.appendChild(cssStyle); +document.head.prepend(cssStyle); const styleSheet = cssStyle.sheet; const cssStyleDark = dom.style(attr.type('text/css')); -document.head.appendChild(cssStyleDark); +document.head.prepend(cssStyleDark); const styleSheetDark = cssStyleDark.sheet; styleSheetDark.insertRule('@media (prefers-color-scheme: dark) {}'); const darkModeRule = styleSheetDark.cssRules[0]; @@ -1085,8 +1085,11 @@ const ensureCSS = (selector, styles, important) => { let darkst; for (let [k, v] of Object.entries(styles)) { // We've kept the camel-case in our code which we had from when we did "st[prop] = - // value". It is more convenient as object keys. So convert to kebab-case. - k = k.replace(/[A-Z]/g, s => '-' + s.toLowerCase()); + // value". It is more convenient as object keys. So convert to kebab-case, but only + // if this is not a css property. + if (!k.startsWith('--')) { + k = k.replace(/[A-Z]/g, s => '-' + s.toLowerCase()); + } if (Array.isArray(v)) { if (v.length !== 2) { throw new Error('2 elements required for light/dark mode style, got ' + v.length); @@ -1112,54 +1115,105 @@ const css = (className, styles, important) => { }; // todo: reduce number of colors. hopefully we can derive some colors from a few base colors (making them brighter/darker, or shifting hue, etc). then make them configurable through settings. // todo: add the standard padding and border-radius, perhaps more. -// todo: could make some of these {prop: value} objects and pass them directly to css() +// We define css variables, making them easy to override. +ensureCSS(':root', { + '--color': ['black', '#ddd'], + '--colorMild': ['#555', '#bbb'], + '--colorMilder': ['#666', '#aaa'], + '--backgroundColor': ['white', '#222'], + '--backgroundColorMild': ['#f8f8f8', '#080808'], + '--backgroundColorMilder': ['#999', '#777'], + '--borderColor': ['#ccc', '#333'], + '--mailboxesTopBackgroundColor': ['#fdfdf1', '#1a1200'], + '--msglistBackgroundColor': ['#f5ffff', '#04130d'], + '--boxShadow': ['0 0 20px rgba(0, 0, 0, 0.1)', '0px 0px 20px #000'], + '--buttonBackground': ['#eee', '#222'], + '--buttonBorderColor': ['#888', '#666'], + '--buttonHoverBackground': ['#ddd', '#333'], + '--overlayOpaqueBackgroundColor': ['#eee', '#011'], + '--overlayBackgroundColor': ['rgba(0, 0, 0, 0.2)', 'rgba(0, 0, 0, 0.5)'], + '--popupColor': ['black', 'white'], + '--popupBackgroundColor': ['white', '#313233'], + '--popupBorderColor': ['#ccc', '#555'], + '--highlightBackground': ['gold', '#a70167'], + '--highlightBorderColor': ['#8c7600', '#fd1fa7'], + '--highlightBackgroundHover': ['#ffbd21', '#710447'], + '--mailboxActiveBackground': ['linear-gradient(135deg, #ffc7ab 0%, #ffdeab 100%)', 'linear-gradient(135deg, #b63d00 0%, #8c5a0d 100%)'], + '--mailboxHoverBackgroundColor': ['#eee', '#421f15'], + '--msgItemActiveBackground': ['linear-gradient(135deg, #8bc8ff 0%, #8ee5ff 100%)', 'linear-gradient(135deg, #045cac 0%, #027ba0 100%)'], + '--msgItemHoverBackgroundColor': ['#eee', '#073348'], + '--msgItemFocusBorderColor': ['#2685ff', '#2685ff'], + '--buttonTristateOnBackground': ['#c4ffa9', '#277e00'], + '--buttonTristateOffBackground': ['#ffb192', '#bf410f'], + '--warningBackgroundColor': ['#ffca91', '#a85700'], + '--successBackground': ['#d2f791', '#1fa204'], + '--emphasisBackground': ['#666', '#aaa'], + // For authentication/security results. + '--underlineGreen': '#50c40f', + '--underlineRed': '#e15d1c', + '--underlineBlue': '#09f', + '--underlineGrey': '#888', + '--quoted1Color': ['#03828f', '#71f2ff'], + '--quoted2Color': ['#c7445c', '#ec4c4c'], + '--quoted3Color': ['#417c10', '#73e614'], + '--scriptSwitchUnderlineColor': ['#dca053', '#e88f1e'], + '--linkColor': ['#096bc2', '#63b6ff'], + '--linkVisitedColor': ['#0704c1', '#c763ff'], +}); +// Typed way to reference a css variables. Kept from before used variables. const styles = { - color: ['black', '#ddd'], - colorMild: ['#555', '#bbb'], - colorMilder: ['#666', '#aaa'], - backgroundColor: ['white', '#222'], - backgroundColorMild: ['#f8f8f8', '#080808'], - backgroundColorMilder: ['#999', '#777'], - borderColor: ['#ccc', '#333'], - mailboxesTopBackgroundColor: ['#fdfdf1', 'rgb(26, 18, 0)'], - msglistBackgroundColor: ['#f5ffff', 'rgb(4, 19, 13)'], - boxShadow: ['0 0 20px rgba(0, 0, 0, 0.1)', '0px 0px 20px #000'], - buttonBackground: ['#eee', '#222'], - buttonBorderColor: ['#888', '#666'], - buttonHoverBackground: ['#ddd', '#333'], - overlayOpaqueBackgroundColor: ['#eee', '#011'], - overlayBackgroundColor: ['rgba(0, 0, 0, 0.2)', 'rgba(0, 0, 0, 0.5)'], - popupColor: ['black', 'white'], - popupBackgroundColor: ['white', 'rgb(49, 50, 51)'], - popupBorderColor: ['#ccc', '#555'], - highlightBackground: ['gold', '#a70167'], - highlightBorderColor: ['#8c7600', 'rgb(253, 31, 167)'], - highlightBackgroundHover: ['#ffbd21', 'rgb(113, 4, 71)'], - mailboxActiveBackground: ['linear-gradient(135deg, #ffc7ab 0%, #ffdeab 100%)', 'linear-gradient(135deg, rgb(182, 61, 0) 0%, rgb(140, 90, 13) 100%)'], - mailboxHoverBackgroundColor: ['#eee', 'rgb(66, 31, 21)'], - msgItemActiveBackground: ['linear-gradient(135deg, #8bc8ff 0%, #8ee5ff 100%)', 'linear-gradient(135deg, rgb(4, 92, 172) 0%, rgb(2, 123, 160) 100%)'], - msgItemHoverBackgroundColor: ['#eee', 'rgb(7, 51, 72)'], - msgItemFocusBorderColor: ['#2685ff', '#2685ff'], - buttonTristateOnBackground: ['#c4ffa9', 'rgb(39, 126, 0)'], - buttonTristateOffBackground: ['#ffb192', 'rgb(191, 65, 15)'], - warningBackgroundColor: ['#ffca91', 'rgb(168, 87, 0)'], - successBackground: ['#d2f791', '#1fa204'], - emphasisBackground: ['#666', '#aaa'], + color: 'var(--color)', + colorMild: 'var(--colorMild)', + colorMilder: 'var(--colorMilder)', + backgroundColor: 'var(--backgroundColor)', + backgroundColorMild: 'var(--backgroundColorMild)', + backgroundColorMilder: 'var(--backgroundColorMilder)', + borderColor: 'var(--borderColor)', + mailboxesTopBackgroundColor: 'var(--mailboxesTopBackgroundColor)', + msglistBackgroundColor: 'var(--msglistBackgroundColor)', + boxShadow: 'var(--boxShadow)', + buttonBackground: 'var(--buttonBackground)', + buttonBorderColor: 'var(--buttonBorderColor)', + buttonHoverBackground: 'var(--buttonHoverBackground)', + overlayOpaqueBackgroundColor: 'var(--overlayOpaqueBackgroundColor)', + overlayBackgroundColor: 'var(--overlayBackgroundColor)', + popupColor: 'var(--popupColor)', + popupBackgroundColor: 'var(--popupBackgroundColor)', + popupBorderColor: 'var(--popupBorderColor)', + highlightBackground: 'var(--highlightBackground)', + highlightBorderColor: 'var(--highlightBorderColor)', + highlightBackgroundHover: 'var(--highlightBackgroundHover)', + mailboxActiveBackground: 'var(--mailboxActiveBackground)', + mailboxHoverBackgroundColor: 'var(--mailboxHoverBackgroundColor)', + msgItemActiveBackground: 'var(--msgItemActiveBackground)', + msgItemHoverBackgroundColor: 'var(--msgItemHoverBackgroundColor)', + msgItemFocusBorderColor: 'var(--msgItemFocusBorderColor)', + buttonTristateOnBackground: 'var(--buttonTristateOnBackground)', + buttonTristateOffBackground: 'var(--buttonTristateOffBackground)', + warningBackgroundColor: 'var(--warningBackgroundColor)', + successBackground: 'var(--successBackground)', + emphasisBackground: 'var(--emphasisBackground)', // For authentication/security results. - underlineGreen: '#50c40f', - underlineRed: '#e15d1c', - underlineBlue: '#09f', - underlineGrey: '#888', + underlineGreen: 'var(--underlineGreen)', + underlineRed: 'var(--underlineRed)', + underlineBlue: 'var(--underlineBlue)', + underlineGrey: 'var(--underlineGrey)', + quoted1Color: 'var(--quoted1Color)', + quoted2Color: 'var(--quoted2Color)', + quoted3Color: 'var(--quoted3Color)', + scriptSwitchUnderlineColor: 'var(--scriptSwitchUnderlineColor)', + linkColor: 'var(--linkColor)', + linkVisitedColor: 'var(--linkVisitedColor)', }; const styleClasses = { // For quoted text, with multiple levels of indentations. quoted: [ - css('quoted1', { color: ['#03828f', '#71f2ff'] }), - css('quoted2', { color: ['#c7445c', 'rgb(236, 76, 76)'] }), - css('quoted3', { color: ['#417c10', 'rgb(115, 230, 20)'] }), // blue + css('quoted1', { color: styles.quoted1Color }), + css('quoted2', { color: styles.quoted2Color }), + css('quoted3', { color: styles.quoted3Color }), ], // When text switches between unicode scripts. - scriptswitch: css('scriptswitch', { textDecoration: 'underline 2px', textDecorationColor: ['#dca053', 'rgb(232, 143, 30)'] }), + scriptswitch: css('scriptswitch', { textDecoration: 'underline 2px', textDecorationColor: styles.scriptSwitchUnderlineColor }), textMild: css('textMild', { color: styles.colorMild }), // For keywords (also known as flags/labels/tags) on messages. keyword: css('keyword', { padding: '0 .15em', borderRadius: '.15em', fontWeight: 'normal', fontSize: '.9em', margin: '0 .15em', whiteSpace: 'nowrap', background: styles.highlightBackground, color: styles.color, border: '1px solid', borderColor: styles.highlightBorderColor }), @@ -1168,15 +1222,15 @@ const styleClasses = { ensureCSS('.msgHeaders td', { wordBreak: 'break-word' }); // Prevent horizontal scroll bar for long header values. ensureCSS('.keyword.keywordCollapsed', { opacity: .75 }), // Generic styling. - ensureCSS('*', { fontSize: 'inherit', fontFamily: "'ubuntu', 'lato', sans-serif", margin: 0, padding: 0, boxSizing: 'border-box' }); + ensureCSS('html', { backgroundColor: 'var(--backgroundColor)', color: 'var(--color)' }); +ensureCSS('*', { fontSize: 'inherit', fontFamily: "'ubuntu', 'lato', sans-serif", margin: 0, padding: 0, boxSizing: 'border-box' }); ensureCSS('.mono, .mono *', { fontFamily: "'ubuntu mono', monospace" }); ensureCSS('table td, table th', { padding: '.15em .25em' }); ensureCSS('.pad', { padding: '.5em' }); ensureCSS('iframe', { border: 0 }); ensureCSS('img, embed, video, iframe', { backgroundColor: 'white', color: 'black' }); -ensureCSS(':root', { backgroundColor: styles.backgroundColor, color: styles.color }); -ensureCSS('a', { color: ['rgb(9, 107, 194)', 'rgb(99, 182, 255)'] }); -ensureCSS('a:visited', { color: ['rgb(7, 4, 193)', 'rgb(199, 99, 255)'] }); +ensureCSS('a', { color: styles.linkColor }); +ensureCSS('a:visited', { color: styles.linkVisitedColor }); // For message view with multiple inline elements (often a single text and multiple messages). ensureCSS('.textmulti > *:nth-child(even)', { backgroundColor: ['#f4f4f4', '#141414'] }); ensureCSS('.textmulti > *', { padding: '2ex .5em', margin: '-.5em' /* compensate pad */ }); @@ -1392,10 +1446,14 @@ const loadMsgheaderView = (msgheaderelem, mi, moreHeaders, refineKeyword, allAdd const init = async () => { const pm = api.parser.ParsedMessage(parsedMessage); const mi = api.parser.MessageItem(messageItem); - dom._kids(document.body, dom.div(dom._class('pad', 'mono', 'textmulti'), css('msgTextPreformatted', { whiteSpace: 'pre-wrap' }), (pm.Texts || []).map(t => renderText(t.replace(/\r\n/g, '\n'))), (mi.Attachments || []).filter(f => isImage(f)).map(f => { + const root = dom.div(dom.div(dom._class('pad', 'mono', 'textmulti'), css('msgTextPreformatted', { whiteSpace: 'pre-wrap' }), (pm.Texts || []).map(t => renderText(t.replace(/\r\n/g, '\n'))), (mi.Attachments || []).filter(f => isImage(f)).map(f => { const pathStr = [0].concat(f.Path || []).join('.'); return dom.div(dom.div(css('msgAttachment', { flexGrow: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', maxHeight: 'calc(100% - 50px)' }), dom.img(attr.src('view/' + pathStr), attr.title(f.Filename), css('msgAttachmentImage', { maxWidth: '100%', maxHeight: '100%', boxShadow: styles.boxShadow })))); }))); + if (typeof moxBeforeDisplay !== 'undefined') { + moxBeforeDisplay(root); + } + dom._kids(document.body, root); }; init() .catch((err) => { diff --git a/webmail/text.ts b/webmail/text.ts index 74798c648..00f494d56 100644 --- a/webmail/text.ts +++ b/webmail/text.ts @@ -3,11 +3,13 @@ // Loaded from synchronous javascript. declare let messageItem: api.MessageItem declare let parsedMessage: api.ParsedMessage +// From customization script. +declare let moxBeforeDisplay: (root: HTMLElement) => void const init = async () => { const pm = api.parser.ParsedMessage(parsedMessage) const mi = api.parser.MessageItem(messageItem) - dom._kids(document.body, + const root = dom.div( dom.div(dom._class('pad', 'mono', 'textmulti'), css('msgTextPreformatted', {whiteSpace: 'pre-wrap'}), (pm.Texts || []).map(t => renderText(t.replace(/\r\n/g, '\n'))), @@ -26,6 +28,10 @@ const init = async () => { }), ) ) + if (typeof moxBeforeDisplay !== 'undefined') { + moxBeforeDisplay(root) + } + dom._kids(document.body, root) } init() diff --git a/webmail/webmail.go b/webmail/webmail.go index c960831f2..8cfa3d7ba 100644 --- a/webmail/webmail.go +++ b/webmail/webmail.go @@ -12,6 +12,7 @@ import ( "errors" "fmt" "io" + "io/fs" "log/slog" "mime" "net/http" @@ -21,6 +22,7 @@ import ( "runtime/debug" "strconv" "strings" + "time" _ "embed" @@ -147,27 +149,62 @@ func xdbread(ctx context.Context, acc *store.Account, fn func(tx *bstore.Tx)) { } var webmailFile = &mox.WebappFile{ - HTML: webmailHTML, - JS: webmailJS, - HTMLPath: filepath.FromSlash("webmail/webmail.html"), - JSPath: filepath.FromSlash("webmail/webmail.js"), + HTML: webmailHTML, + JS: webmailJS, + HTMLPath: filepath.FromSlash("webmail/webmail.html"), + JSPath: filepath.FromSlash("webmail/webmail.js"), + CustomStem: "webmail", } -// Serve content, either from a file, or return the fallback data. Caller -// should already have set the content-type. We use this to return a file from -// the local file system (during development), or embedded in the binary (when -// deployed). -func serveContentFallback(log mlog.Log, w http.ResponseWriter, r *http.Request, path string, fallback []byte) { +func customization() (css, js []byte, err error) { + if css, err = os.ReadFile(mox.ConfigDirPath("webmail.css")); err != nil && !errors.Is(err, fs.ErrNotExist) { + return nil, nil, err + } + if js, err = os.ReadFile(mox.ConfigDirPath("webmail.js")); err != nil && !errors.Is(err, fs.ErrNotExist) { + return nil, nil, err + } + css = append([]byte("/* Custom CSS by admin from $configdir/webmail.css: */\n"), css...) + js = append([]byte("// Custom JS by admin from $configdir/webmail.js:\n"), js...) + js = append(js, '\n') + return css, js, nil +} + +// Serve HTML content, either from a file, or return the fallback data. If +// customize is set, css/js is inserted if configured. Caller should already have +// set the content-type. We use this to return a file from the local file system +// (during development), or embedded in the binary (when deployed). +func serveContentFallback(log mlog.Log, w http.ResponseWriter, r *http.Request, path string, fallback []byte, customize bool) { + serve := func(mtime time.Time, rd io.ReadSeeker) { + if customize { + buf, err := io.ReadAll(rd) + if err != nil { + log.Errorx("reading content to customize", err) + http.Error(w, "500 - internal server error - reading content to customize", http.StatusInternalServerError) + return + } + customCSS, customJS, err := customization() + if err != nil { + log.Errorx("reading customizations", err) + http.Error(w, "500 - internal server error - reading customizations", http.StatusInternalServerError) + return + } + buf = bytes.Replace(buf, []byte("/* css placeholder */"), customCSS, 1) + buf = bytes.Replace(buf, []byte("/* js placeholder */"), customJS, 1) + rd = bytes.NewReader(buf) + } + http.ServeContent(w, r, "", mtime, rd) + } + f, err := os.Open(path) if err == nil { defer f.Close() st, err := f.Stat() if err == nil { - http.ServeContent(w, r, "", st.ModTime(), f) + serve(st.ModTime(), f) return } } - http.ServeContent(w, r, "", mox.FallbackMtime(log), bytes.NewReader(fallback)) + serve(mox.FallbackMtime(log), bytes.NewReader(fallback)) } func init() { @@ -261,7 +298,7 @@ func handle(apiHandler http.Handler, isForwarded bool, accountPath string, w htt } w.Header().Set("Content-Type", "application/javascript; charset=utf-8") - serveContentFallback(log, w, r, path, fallback) + serveContentFallback(log, w, r, path, fallback, false) return } @@ -621,7 +658,7 @@ func handle(apiHandler http.Handler, isForwarded bool, accountPath string, w htt path := filepath.FromSlash("webmail/msg.html") fallback := webmailmsgHTML - serveContentFallback(log, w, r, path, fallback) + serveContentFallback(log, w, r, path, fallback, true) case len(t) == 2 && t[1] == "parsedmessage.js": // Used by msg.html, for the msg* endpoints, for the data needed to show all data @@ -689,7 +726,7 @@ func handle(apiHandler http.Handler, isForwarded bool, accountPath string, w htt // from disk. path := filepath.FromSlash("webmail/text.html") fallback := webmailtextHTML - serveContentFallback(log, w, r, path, fallback) + serveContentFallback(log, w, r, path, fallback, true) case len(t) == 2 && (t[1] == "html" || t[1] == "htmlexternal"): // Returns the first HTML part, with "cid:" URIs replaced with an inlined datauri diff --git a/webmail/webmail.html b/webmail/webmail.html index 24669e7cd..3819cc7ae 100644 --- a/webmail/webmail.html +++ b/webmail/webmail.html @@ -14,10 +14,14 @@ @keyframes fadein { 0% { opacity: 0 } 100% { opacity: 1 } } @keyframes fadeout { 0% { opacity: 1 } 100% { opacity: 0.1 } } .invert { filter: invert(100%); } + +/* css placeholder */
Loading...
- + diff --git a/webmail/webmail.js b/webmail/webmail.js index 863864e71..733b139ee 100644 --- a/webmail/webmail.js +++ b/webmail/webmail.js @@ -1055,10 +1055,10 @@ var api; // instances of a class. // We keep the default/regular styles and dark-mode styles in separate stylesheets. const cssStyle = dom.style(attr.type('text/css')); -document.head.appendChild(cssStyle); +document.head.prepend(cssStyle); const styleSheet = cssStyle.sheet; const cssStyleDark = dom.style(attr.type('text/css')); -document.head.appendChild(cssStyleDark); +document.head.prepend(cssStyleDark); const styleSheetDark = cssStyleDark.sheet; styleSheetDark.insertRule('@media (prefers-color-scheme: dark) {}'); const darkModeRule = styleSheetDark.cssRules[0]; @@ -1085,8 +1085,11 @@ const ensureCSS = (selector, styles, important) => { let darkst; for (let [k, v] of Object.entries(styles)) { // We've kept the camel-case in our code which we had from when we did "st[prop] = - // value". It is more convenient as object keys. So convert to kebab-case. - k = k.replace(/[A-Z]/g, s => '-' + s.toLowerCase()); + // value". It is more convenient as object keys. So convert to kebab-case, but only + // if this is not a css property. + if (!k.startsWith('--')) { + k = k.replace(/[A-Z]/g, s => '-' + s.toLowerCase()); + } if (Array.isArray(v)) { if (v.length !== 2) { throw new Error('2 elements required for light/dark mode style, got ' + v.length); @@ -1112,54 +1115,105 @@ const css = (className, styles, important) => { }; // todo: reduce number of colors. hopefully we can derive some colors from a few base colors (making them brighter/darker, or shifting hue, etc). then make them configurable through settings. // todo: add the standard padding and border-radius, perhaps more. -// todo: could make some of these {prop: value} objects and pass them directly to css() +// We define css variables, making them easy to override. +ensureCSS(':root', { + '--color': ['black', '#ddd'], + '--colorMild': ['#555', '#bbb'], + '--colorMilder': ['#666', '#aaa'], + '--backgroundColor': ['white', '#222'], + '--backgroundColorMild': ['#f8f8f8', '#080808'], + '--backgroundColorMilder': ['#999', '#777'], + '--borderColor': ['#ccc', '#333'], + '--mailboxesTopBackgroundColor': ['#fdfdf1', '#1a1200'], + '--msglistBackgroundColor': ['#f5ffff', '#04130d'], + '--boxShadow': ['0 0 20px rgba(0, 0, 0, 0.1)', '0px 0px 20px #000'], + '--buttonBackground': ['#eee', '#222'], + '--buttonBorderColor': ['#888', '#666'], + '--buttonHoverBackground': ['#ddd', '#333'], + '--overlayOpaqueBackgroundColor': ['#eee', '#011'], + '--overlayBackgroundColor': ['rgba(0, 0, 0, 0.2)', 'rgba(0, 0, 0, 0.5)'], + '--popupColor': ['black', 'white'], + '--popupBackgroundColor': ['white', '#313233'], + '--popupBorderColor': ['#ccc', '#555'], + '--highlightBackground': ['gold', '#a70167'], + '--highlightBorderColor': ['#8c7600', '#fd1fa7'], + '--highlightBackgroundHover': ['#ffbd21', '#710447'], + '--mailboxActiveBackground': ['linear-gradient(135deg, #ffc7ab 0%, #ffdeab 100%)', 'linear-gradient(135deg, #b63d00 0%, #8c5a0d 100%)'], + '--mailboxHoverBackgroundColor': ['#eee', '#421f15'], + '--msgItemActiveBackground': ['linear-gradient(135deg, #8bc8ff 0%, #8ee5ff 100%)', 'linear-gradient(135deg, #045cac 0%, #027ba0 100%)'], + '--msgItemHoverBackgroundColor': ['#eee', '#073348'], + '--msgItemFocusBorderColor': ['#2685ff', '#2685ff'], + '--buttonTristateOnBackground': ['#c4ffa9', '#277e00'], + '--buttonTristateOffBackground': ['#ffb192', '#bf410f'], + '--warningBackgroundColor': ['#ffca91', '#a85700'], + '--successBackground': ['#d2f791', '#1fa204'], + '--emphasisBackground': ['#666', '#aaa'], + // For authentication/security results. + '--underlineGreen': '#50c40f', + '--underlineRed': '#e15d1c', + '--underlineBlue': '#09f', + '--underlineGrey': '#888', + '--quoted1Color': ['#03828f', '#71f2ff'], + '--quoted2Color': ['#c7445c', '#ec4c4c'], + '--quoted3Color': ['#417c10', '#73e614'], + '--scriptSwitchUnderlineColor': ['#dca053', '#e88f1e'], + '--linkColor': ['#096bc2', '#63b6ff'], + '--linkVisitedColor': ['#0704c1', '#c763ff'], +}); +// Typed way to reference a css variables. Kept from before used variables. const styles = { - color: ['black', '#ddd'], - colorMild: ['#555', '#bbb'], - colorMilder: ['#666', '#aaa'], - backgroundColor: ['white', '#222'], - backgroundColorMild: ['#f8f8f8', '#080808'], - backgroundColorMilder: ['#999', '#777'], - borderColor: ['#ccc', '#333'], - mailboxesTopBackgroundColor: ['#fdfdf1', 'rgb(26, 18, 0)'], - msglistBackgroundColor: ['#f5ffff', 'rgb(4, 19, 13)'], - boxShadow: ['0 0 20px rgba(0, 0, 0, 0.1)', '0px 0px 20px #000'], - buttonBackground: ['#eee', '#222'], - buttonBorderColor: ['#888', '#666'], - buttonHoverBackground: ['#ddd', '#333'], - overlayOpaqueBackgroundColor: ['#eee', '#011'], - overlayBackgroundColor: ['rgba(0, 0, 0, 0.2)', 'rgba(0, 0, 0, 0.5)'], - popupColor: ['black', 'white'], - popupBackgroundColor: ['white', 'rgb(49, 50, 51)'], - popupBorderColor: ['#ccc', '#555'], - highlightBackground: ['gold', '#a70167'], - highlightBorderColor: ['#8c7600', 'rgb(253, 31, 167)'], - highlightBackgroundHover: ['#ffbd21', 'rgb(113, 4, 71)'], - mailboxActiveBackground: ['linear-gradient(135deg, #ffc7ab 0%, #ffdeab 100%)', 'linear-gradient(135deg, rgb(182, 61, 0) 0%, rgb(140, 90, 13) 100%)'], - mailboxHoverBackgroundColor: ['#eee', 'rgb(66, 31, 21)'], - msgItemActiveBackground: ['linear-gradient(135deg, #8bc8ff 0%, #8ee5ff 100%)', 'linear-gradient(135deg, rgb(4, 92, 172) 0%, rgb(2, 123, 160) 100%)'], - msgItemHoverBackgroundColor: ['#eee', 'rgb(7, 51, 72)'], - msgItemFocusBorderColor: ['#2685ff', '#2685ff'], - buttonTristateOnBackground: ['#c4ffa9', 'rgb(39, 126, 0)'], - buttonTristateOffBackground: ['#ffb192', 'rgb(191, 65, 15)'], - warningBackgroundColor: ['#ffca91', 'rgb(168, 87, 0)'], - successBackground: ['#d2f791', '#1fa204'], - emphasisBackground: ['#666', '#aaa'], + color: 'var(--color)', + colorMild: 'var(--colorMild)', + colorMilder: 'var(--colorMilder)', + backgroundColor: 'var(--backgroundColor)', + backgroundColorMild: 'var(--backgroundColorMild)', + backgroundColorMilder: 'var(--backgroundColorMilder)', + borderColor: 'var(--borderColor)', + mailboxesTopBackgroundColor: 'var(--mailboxesTopBackgroundColor)', + msglistBackgroundColor: 'var(--msglistBackgroundColor)', + boxShadow: 'var(--boxShadow)', + buttonBackground: 'var(--buttonBackground)', + buttonBorderColor: 'var(--buttonBorderColor)', + buttonHoverBackground: 'var(--buttonHoverBackground)', + overlayOpaqueBackgroundColor: 'var(--overlayOpaqueBackgroundColor)', + overlayBackgroundColor: 'var(--overlayBackgroundColor)', + popupColor: 'var(--popupColor)', + popupBackgroundColor: 'var(--popupBackgroundColor)', + popupBorderColor: 'var(--popupBorderColor)', + highlightBackground: 'var(--highlightBackground)', + highlightBorderColor: 'var(--highlightBorderColor)', + highlightBackgroundHover: 'var(--highlightBackgroundHover)', + mailboxActiveBackground: 'var(--mailboxActiveBackground)', + mailboxHoverBackgroundColor: 'var(--mailboxHoverBackgroundColor)', + msgItemActiveBackground: 'var(--msgItemActiveBackground)', + msgItemHoverBackgroundColor: 'var(--msgItemHoverBackgroundColor)', + msgItemFocusBorderColor: 'var(--msgItemFocusBorderColor)', + buttonTristateOnBackground: 'var(--buttonTristateOnBackground)', + buttonTristateOffBackground: 'var(--buttonTristateOffBackground)', + warningBackgroundColor: 'var(--warningBackgroundColor)', + successBackground: 'var(--successBackground)', + emphasisBackground: 'var(--emphasisBackground)', // For authentication/security results. - underlineGreen: '#50c40f', - underlineRed: '#e15d1c', - underlineBlue: '#09f', - underlineGrey: '#888', + underlineGreen: 'var(--underlineGreen)', + underlineRed: 'var(--underlineRed)', + underlineBlue: 'var(--underlineBlue)', + underlineGrey: 'var(--underlineGrey)', + quoted1Color: 'var(--quoted1Color)', + quoted2Color: 'var(--quoted2Color)', + quoted3Color: 'var(--quoted3Color)', + scriptSwitchUnderlineColor: 'var(--scriptSwitchUnderlineColor)', + linkColor: 'var(--linkColor)', + linkVisitedColor: 'var(--linkVisitedColor)', }; const styleClasses = { // For quoted text, with multiple levels of indentations. quoted: [ - css('quoted1', { color: ['#03828f', '#71f2ff'] }), - css('quoted2', { color: ['#c7445c', 'rgb(236, 76, 76)'] }), - css('quoted3', { color: ['#417c10', 'rgb(115, 230, 20)'] }), // blue + css('quoted1', { color: styles.quoted1Color }), + css('quoted2', { color: styles.quoted2Color }), + css('quoted3', { color: styles.quoted3Color }), ], // When text switches between unicode scripts. - scriptswitch: css('scriptswitch', { textDecoration: 'underline 2px', textDecorationColor: ['#dca053', 'rgb(232, 143, 30)'] }), + scriptswitch: css('scriptswitch', { textDecoration: 'underline 2px', textDecorationColor: styles.scriptSwitchUnderlineColor }), textMild: css('textMild', { color: styles.colorMild }), // For keywords (also known as flags/labels/tags) on messages. keyword: css('keyword', { padding: '0 .15em', borderRadius: '.15em', fontWeight: 'normal', fontSize: '.9em', margin: '0 .15em', whiteSpace: 'nowrap', background: styles.highlightBackground, color: styles.color, border: '1px solid', borderColor: styles.highlightBorderColor }), @@ -1168,15 +1222,15 @@ const styleClasses = { ensureCSS('.msgHeaders td', { wordBreak: 'break-word' }); // Prevent horizontal scroll bar for long header values. ensureCSS('.keyword.keywordCollapsed', { opacity: .75 }), // Generic styling. - ensureCSS('*', { fontSize: 'inherit', fontFamily: "'ubuntu', 'lato', sans-serif", margin: 0, padding: 0, boxSizing: 'border-box' }); + ensureCSS('html', { backgroundColor: 'var(--backgroundColor)', color: 'var(--color)' }); +ensureCSS('*', { fontSize: 'inherit', fontFamily: "'ubuntu', 'lato', sans-serif", margin: 0, padding: 0, boxSizing: 'border-box' }); ensureCSS('.mono, .mono *', { fontFamily: "'ubuntu mono', monospace" }); ensureCSS('table td, table th', { padding: '.15em .25em' }); ensureCSS('.pad', { padding: '.5em' }); ensureCSS('iframe', { border: 0 }); ensureCSS('img, embed, video, iframe', { backgroundColor: 'white', color: 'black' }); -ensureCSS(':root', { backgroundColor: styles.backgroundColor, color: styles.color }); -ensureCSS('a', { color: ['rgb(9, 107, 194)', 'rgb(99, 182, 255)'] }); -ensureCSS('a:visited', { color: ['rgb(7, 4, 193)', 'rgb(199, 99, 255)'] }); +ensureCSS('a', { color: styles.linkColor }); +ensureCSS('a:visited', { color: styles.linkVisitedColor }); // For message view with multiple inline elements (often a single text and multiple messages). ensureCSS('.textmulti > *:nth-child(even)', { backgroundColor: ['#f4f4f4', '#141414'] }); ensureCSS('.textmulti > *', { padding: '2ex .5em', margin: '-.5em' /* compensate pad */ }); @@ -6781,6 +6835,9 @@ const init = async () => { else { selectLayout(layoutElem.value); } + if (window.moxBeforeDisplay) { + moxBeforeDisplay(webmailroot); + } dom._kids(page, webmailroot); checkMsglistWidth(); window.addEventListener('resize', function () { diff --git a/webmail/webmail.ts b/webmail/webmail.ts index 0798c14b2..ea265a874 100644 --- a/webmail/webmail.ts +++ b/webmail/webmail.ts @@ -133,6 +133,8 @@ declare let page: HTMLElement declare let moxversion: string declare let moxgoos: string declare let moxgoarch: string +// From customization script. +declare let moxBeforeDisplay: (root: HTMLElement) => void // All logging goes through log() instead of console.log, except "should not happen" logging. let log: (...args: any[]) => void = () => {} @@ -7057,6 +7059,9 @@ const init = async () => { } else { selectLayout(layoutElem.value) } + if ((window as any).moxBeforeDisplay) { + moxBeforeDisplay(webmailroot) + } dom._kids(page, webmailroot) checkMsglistWidth() From 09e7ddba9e9e38ca5387519f5bbc8db324f2aa5d Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Fri, 29 Nov 2024 10:40:22 +0100 Subject: [PATCH 09/20] web apps: add autocomplete attribute for usernames and passwords hinted at by chromium developer console --- webaccount/account.js | 2 +- webaccount/account.ts | 3 ++- webadmin/admin.js | 2 +- webadmin/admin.ts | 2 +- webmail/webmail.js | 2 +- webmail/webmail.ts | 3 ++- 6 files changed, 8 insertions(+), 6 deletions(-) diff --git a/webaccount/account.js b/webaccount/account.js index e30e6c4e7..526dc6bbe 100644 --- a/webaccount/account.js +++ b/webaccount/account.js @@ -913,7 +913,7 @@ const login = async (reason) => { finally { fieldset.disabled = false; } - }, fieldset = dom.fieldset(dom.h1('Account'), dom.label(style({ display: 'block', marginBottom: '2ex' }), dom.div('Email address', style({ marginBottom: '.5ex' })), autosize = dom.span(dom._class('autosize'), username = dom.input(attr.required(''), attr.placeholder('jane@example.org'), function change() { autosize.dataset.value = username.value; }, function input() { autosize.dataset.value = username.value; }))), dom.label(style({ display: 'block', marginBottom: '2ex' }), dom.div('Password', style({ marginBottom: '.5ex' })), password = dom.input(attr.type('password'), attr.required(''))), dom.div(style({ textAlign: 'center' }), dom.submitbutton('Login'))))))); + }, fieldset = dom.fieldset(dom.h1('Account'), dom.label(style({ display: 'block', marginBottom: '2ex' }), dom.div('Email address', style({ marginBottom: '.5ex' })), autosize = dom.span(dom._class('autosize'), username = dom.input(attr.required(''), attr.autocomplete('username'), attr.placeholder('jane@example.org'), function change() { autosize.dataset.value = username.value; }, function input() { autosize.dataset.value = username.value; }))), dom.label(style({ display: 'block', marginBottom: '2ex' }), dom.div('Password', style({ marginBottom: '.5ex' })), password = dom.input(attr.type('password'), attr.autocomplete('current-password'), attr.required(''))), dom.div(style({ textAlign: 'center' }), dom.submitbutton('Login'))))))); document.body.appendChild(root); username.focus(); }); diff --git a/webaccount/account.ts b/webaccount/account.ts index 631f9cb67..514429964 100644 --- a/webaccount/account.ts +++ b/webaccount/account.ts @@ -61,6 +61,7 @@ const login = async (reason: string) => { autosize=dom.span(dom._class('autosize'), username=dom.input( attr.required(''), + attr.autocomplete('username'), attr.placeholder('jane@example.org'), function change() { autosize.dataset.value = username.value }, function input() { autosize.dataset.value = username.value }, @@ -70,7 +71,7 @@ const login = async (reason: string) => { dom.label( style({display: 'block', marginBottom: '2ex'}), dom.div('Password', style({marginBottom: '.5ex'})), - password=dom.input(attr.type('password'), attr.required('')), + password=dom.input(attr.type('password'), attr.autocomplete('current-password'), attr.required('')), ), dom.div( style({textAlign: 'center'}), diff --git a/webadmin/admin.js b/webadmin/admin.js index 8dacf740f..627f5b3bf 100644 --- a/webadmin/admin.js +++ b/webadmin/admin.js @@ -1676,7 +1676,7 @@ const login = async (reason) => { finally { fieldset.disabled = false; } - }, fieldset = dom.fieldset(dom.h1('Admin'), dom.label(style({ display: 'block', marginBottom: '2ex' }), dom.div('Password', style({ marginBottom: '.5ex' })), password = dom.input(attr.type('password'), attr.required(''))), dom.div(style({ textAlign: 'center' }), dom.submitbutton('Login'))))))); + }, fieldset = dom.fieldset(dom.h1('Admin'), dom.label(style({ display: 'block', marginBottom: '2ex' }), dom.div('Password', style({ marginBottom: '.5ex' })), password = dom.input(attr.type('password'), attr.autocomplete('current-password'), attr.required(''))), dom.div(style({ textAlign: 'center' }), dom.submitbutton('Login'))))))); document.body.appendChild(root); password.focus(); }); diff --git a/webadmin/admin.ts b/webadmin/admin.ts index c953f4b06..4d1e47d91 100644 --- a/webadmin/admin.ts +++ b/webadmin/admin.ts @@ -54,7 +54,7 @@ const login = async (reason: string) => { dom.label( style({display: 'block', marginBottom: '2ex'}), dom.div('Password', style({marginBottom: '.5ex'})), - password=dom.input(attr.type('password'), attr.required('')), + password=dom.input(attr.type('password'), attr.autocomplete('current-password'), attr.required('')), ), dom.div( style({textAlign: 'center'}), diff --git a/webmail/webmail.js b/webmail/webmail.js index 733b139ee..4535659b0 100644 --- a/webmail/webmail.js +++ b/webmail/webmail.js @@ -1725,7 +1725,7 @@ const login = async (reason) => { finally { fieldset.disabled = false; } - }, fieldset = dom.fieldset(dom.h1('Mail'), dom.label(style({ display: 'block', marginBottom: '2ex' }), dom.div('Email address', style({ marginBottom: '.5ex' })), autosize = dom.span(dom._class('autosize'), username = dom.input(attr.required(''), attr.placeholder('jane@example.org'), function change() { autosize.dataset.value = username.value; }, function input() { autosize.dataset.value = username.value; }))), dom.label(style({ display: 'block', marginBottom: '2ex' }), dom.div('Password', style({ marginBottom: '.5ex' })), password = dom.input(attr.type('password'), attr.required(''))), dom.div(style({ textAlign: 'center' }), dom.submitbutton('Login'))))))); + }, fieldset = dom.fieldset(dom.h1('Mail'), dom.label(style({ display: 'block', marginBottom: '2ex' }), dom.div('Email address', style({ marginBottom: '.5ex' })), autosize = dom.span(dom._class('autosize'), username = dom.input(attr.required(''), attr.autocomplete('username'), attr.placeholder('jane@example.org'), function change() { autosize.dataset.value = username.value; }, function input() { autosize.dataset.value = username.value; }))), dom.label(style({ display: 'block', marginBottom: '2ex' }), dom.div('Password', style({ marginBottom: '.5ex' })), password = dom.input(attr.type('password'), attr.autocomplete('current-password'), attr.required(''))), dom.div(style({ textAlign: 'center' }), dom.submitbutton('Login'))))))); document.body.appendChild(root); username.focus(); }); diff --git a/webmail/webmail.ts b/webmail/webmail.ts index ea265a874..3c24cdd3a 100644 --- a/webmail/webmail.ts +++ b/webmail/webmail.ts @@ -324,6 +324,7 @@ const login = async (reason: string) => { autosize=dom.span(dom._class('autosize'), username=dom.input( attr.required(''), + attr.autocomplete('username'), attr.placeholder('jane@example.org'), function change() { autosize.dataset.value = username.value }, function input() { autosize.dataset.value = username.value }, @@ -333,7 +334,7 @@ const login = async (reason: string) => { dom.label( style({display: 'block', marginBottom: '2ex'}), dom.div('Password', style({marginBottom: '.5ex'})), - password=dom.input(attr.type('password'), attr.required('')), + password=dom.input(attr.type('password'), attr.autocomplete('current-password'), attr.required('')), ), dom.div( style({textAlign: 'center'}), From afb182cb14def32987f11200539806af83bb30c5 Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Fri, 29 Nov 2024 12:43:21 +0100 Subject: [PATCH 10/20] smtpserver: add prometheus metric for failing starttls handshakes for incoming deliveries and add an alerting rule if the failure rate becomes >10% (e.g. expired certificate). the prometheus metrics includes a reason, including potential tls alerts, if remote smtp clients would send those (openssl s_client -starttls does). inspired by issue #237, where incoming connections were aborted by remote. such errors would show up as "eof" in the metrics. --- prometheus.rules | 7 +++++- smtpserver/server.go | 51 +++++++++++++++++++++++++++++++++++++++++++ tlsrpt/alert.go | 3 ++- tlsrpt/alert_go120.go | 3 ++- tlsrpt/report.go | 4 ++-- 5 files changed, 63 insertions(+), 5 deletions(-) diff --git a/prometheus.rules b/prometheus.rules index 1f75074d2..b134935ea 100644 --- a/prometheus.rules +++ b/prometheus.rules @@ -62,9 +62,14 @@ groups: # the alerts below can be used to keep a closer eye or when starting to use mox, # but can be noisy, or you may not be able to prevent them. + - alert: mox-incoming-delivery-starttls-errors + expr: sum by (instance) (increase(mox_smtpserver_delivery_starttls_errors_total[1h])) / sum by (instance) (increase(mox_smtpserver_delivery_starttls_total[1h])) > 0.1 + annotations: + summary: starttls handshake errors for >10% of incoming smtp delivery connections + # change period to match your expected incoming message rate. - alert: mox-no-deliveries - expr: sum(rate(mox_smtpserver_delivery_total{result="delivered"}[6h])) == 0 + expr: sum by (instance) (rate(mox_smtpserver_delivery_total{result="delivered"}[6h])) == 0 annotations: summary: no mail delivered for 6 hours diff --git a/smtpserver/server.go b/smtpserver/server.go index dd87ec754..6e112667d 100644 --- a/smtpserver/server.go +++ b/smtpserver/server.go @@ -22,6 +22,7 @@ import ( "net" "net/textproto" "os" + "reflect" "runtime/debug" "slices" "sort" @@ -59,6 +60,7 @@ import ( "github.com/mjl-/mox/smtp" "github.com/mjl-/mox/spf" "github.com/mjl-/mox/store" + "github.com/mjl-/mox/tlsrpt" "github.com/mjl-/mox/tlsrptdb" ) @@ -171,6 +173,21 @@ var ( "error", }, ) + metricDeliveryStarttls = promauto.NewCounter( + prometheus.CounterOpts{ + Name: "mox_smtpserver_delivery_starttls_total", + Help: "Total number of STARTTLS handshakes for incoming deliveries.", + }, + ) + metricDeliveryStarttlsErrors = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "mox_smtpserver_delivery_starttls_errors_total", + Help: "Errors with TLS handshake during STARTTLS for incoming deliveries.", + }, + []string{ + "reason", // "eof", "sslv2", "unsupportedversions", "nottls", "alert--", "other" + }, + ) ) var jitterRand = mox.NewPseudoRand() @@ -955,7 +972,25 @@ func (c *conn) cmdStarttls(p *parser) { ctx, cancel := context.WithTimeout(cidctx, time.Minute) defer cancel() c.log.Debug("starting tls server handshake") + metricDeliveryStarttls.Inc() if err := tlsConn.HandshakeContext(ctx); err != nil { + // Errors from crypto/tls mostly aren't typed. We'll have to look for strings... + reason := "other" + if errors.Is(err, io.EOF) { + reason = "eof" + } else if alert, ok := asTLSAlert(err); ok { + reason = tlsrpt.FormatAlert(alert) + } else { + s := err.Error() + if strings.Contains(s, "tls: client offered only unsupported versions") { + reason = "unsupportedversions" + } else if strings.Contains(s, "tls: first record does not look like a TLS handshake") { + reason = "nottls" + } else if strings.Contains(s, "tls: unsupported SSLv2 handshake received") { + reason = "sslv2" + } + } + metricDeliveryStarttlsErrors.WithLabelValues(reason).Inc() panic(fmt.Errorf("starttls handshake: %s (%w)", err, errIO)) } cancel() @@ -971,6 +1006,22 @@ func (c *conn) cmdStarttls(p *parser) { c.tls = true } +func asTLSAlert(err error) (alert uint8, ok bool) { + // If the remote client aborts the connection, it can send an alert indicating why. + // crypto/tls gives us a net.OpError with "Op" set to "remote error", an an Err + // with the unexported type "alert", a uint8. So we try to read it. + + var opErr *net.OpError + if !errors.As(err, &opErr) || opErr.Op != "remote error" || opErr.Err == nil { + return + } + v := reflect.ValueOf(opErr.Err) + if v.Kind() != reflect.Uint8 || v.Type().Name() != "alert" { + return + } + return uint8(v.Uint()), true +} + // ../rfc/4954:139 func (c *conn) cmdAuth(p *parser) { c.xneedHello() diff --git a/tlsrpt/alert.go b/tlsrpt/alert.go index 3f4ca8b8c..227bb2af8 100644 --- a/tlsrpt/alert.go +++ b/tlsrpt/alert.go @@ -10,7 +10,8 @@ import ( "strings" ) -func formatAlert(alert uint8) string { +// FormatAlert formats a TLS alert in the form "alert-" or "alert--". +func FormatAlert(alert uint8) string { s := fmt.Sprintf("alert-%d", alert) err := tls.AlertError(alert) // Since go1.21.0 // crypto/tls returns messages like "tls: short message" or "tls: alert(321)". diff --git a/tlsrpt/alert_go120.go b/tlsrpt/alert_go120.go index c686a523c..a2a396add 100644 --- a/tlsrpt/alert_go120.go +++ b/tlsrpt/alert_go120.go @@ -8,6 +8,7 @@ import ( "fmt" ) -func formatAlert(alert uint8) string { +// FormatAlert formats a TLS alert in the form "alert-". +func FormatAlert(alert uint8) string { return fmt.Sprintf("alert-%d", alert) } diff --git a/tlsrpt/report.go b/tlsrpt/report.go index 5e09d331d..a25cc377c 100644 --- a/tlsrpt/report.go +++ b/tlsrpt/report.go @@ -394,7 +394,7 @@ func TLSFailureDetails(err error) (ResultType, string) { // todo: ideally, crypto/tls would let us check if this is an alert. it could be another uint8-typed error. v := reflect.ValueOf(netErr.Err) if v.Kind() == reflect.Uint8 && v.Type().Name() == "alert" { - reasonCode = "tls-remote-" + formatAlert(uint8(v.Uint())) + reasonCode = "tls-remote-" + FormatAlert(uint8(v.Uint())) } } return ResultValidationFailure, reasonCode @@ -429,7 +429,7 @@ func TLSFailureDetails(err error) (ResultType, string) { } v := reflect.ValueOf(err) if v.Kind() == reflect.Uint8 && v.Type().Name() == "alert" { - reasonCode = "tls-local-" + formatAlert(uint8(v.Uint())) + reasonCode = "tls-local-" + FormatAlert(uint8(v.Uint())) } } return ResultValidationFailure, reasonCode From 96a3ecd52c7f1e4786c3b7089145cf2a562b001d Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Fri, 29 Nov 2024 13:17:13 +0100 Subject: [PATCH 11/20] use reflect.TypeFor instead of kludgy reflect.TypeOf TypeFor was introduced in go1.22, which we already require. --- main.go | 2 +- webapisrv/server.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/main.go b/main.go index ec2b662c0..38b4d144d 100644 --- a/main.go +++ b/main.go @@ -2856,7 +2856,7 @@ func cmdWebapi(c *cmd) { c.Usage() } - t := reflect.TypeOf((*webapi.Methods)(nil)).Elem() + t := reflect.TypeFor[webapi.Methods]() methods := map[string]reflect.Type{} var ml []string for i := 0; i < t.NumMethod(); i++ { diff --git a/webapisrv/server.go b/webapisrv/server.go index 448c43377..84305da04 100644 --- a/webapisrv/server.go +++ b/webapisrv/server.go @@ -199,7 +199,7 @@ var docsIndex []byte func init() { var methods []string - mt := reflect.TypeOf((*webapi.Methods)(nil)).Elem() + mt := reflect.TypeFor[webapi.Methods]() n := mt.NumMethod() for i := 0; i < n; i++ { methods = append(methods, mt.Method(i).Name) From de435fceba87b8971dec81bce30bf97c172d90d8 Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Fri, 29 Nov 2024 13:45:19 +0100 Subject: [PATCH 12/20] switch to math/rand/v2 in most places this allows removing some ugly instantiations of an rng based on the current time. Intn is now IntN for our concurrency-safe prng wrapper to match the randv2 api. v2 exists since go1.22, which we already require. --- dmarc/dmarc.go | 4 ++-- dmarcdb/eval.go | 2 +- junk.go | 8 ++++---- mox-/rand.go | 20 +++++++++----------- mtastsdb/refresh.go | 7 +++---- queue/hook.go | 2 +- queue/queue.go | 2 +- tlsrptsend/send.go | 2 +- webmail/eventwriter.go | 7 ++----- 9 files changed, 24 insertions(+), 30 deletions(-) diff --git a/dmarc/dmarc.go b/dmarc/dmarc.go index ccfc8b196..3b59ca49e 100644 --- a/dmarc/dmarc.go +++ b/dmarc/dmarc.go @@ -15,7 +15,7 @@ import ( "errors" "fmt" "log/slog" - mathrand "math/rand" + mathrand2 "math/rand/v2" "time" "github.com/mjl-/mox/dkim" @@ -257,7 +257,7 @@ func Verify(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, msgFr // Record can request sampling of messages to apply policy. // See ../rfc/7489:1432 - useResult = !applyRandomPercentage || record.Percentage == 100 || mathrand.Intn(100) < record.Percentage + useResult = !applyRandomPercentage || record.Percentage == 100 || mathrand2.IntN(100) < record.Percentage // We treat "quarantine" and "reject" the same. Thus, we also don't "downgrade" // from reject to quarantine if this message was sampled out. diff --git a/dmarcdb/eval.go b/dmarcdb/eval.go index 9b893e18f..1740f31ef 100644 --- a/dmarcdb/eval.go +++ b/dmarcdb/eval.go @@ -252,7 +252,7 @@ var jitterRand = mox.NewPseudoRand() // Jitter so we don't cause load at exactly whole hours, other processes may // already be doing that. var jitteredTimeUntil = func(t time.Time) time.Duration { - return time.Until(t.Add(time.Duration(30+jitterRand.Intn(60)) * time.Second)) + return time.Until(t.Add(time.Duration(30+jitterRand.IntN(60)) * time.Second)) } // Start launches a goroutine that wakes up at each whole hour (plus jitter) and diff --git a/junk.go b/junk.go index 0aabc3dd1..aa00bfee9 100644 --- a/junk.go +++ b/junk.go @@ -214,12 +214,12 @@ messages are shuffled, with optional random seed.` hamFiles := listDir(hamDir) spamFiles := listDir(spamDir) - var rand *mathrand.Rand + var seed int64 if a.seed { - rand = mathrand.New(mathrand.NewSource(time.Now().UnixMilli())) - } else { - rand = mathrand.New(mathrand.NewSource(0)) + seed = time.Now().UnixMilli() } + // Still at math/rand (v1 instead of v2) for potential comparison to earlier test results. + rand := mathrand.New(mathrand.NewSource(seed)) shuffle := func(l []string) { count := len(l) diff --git a/mox-/rand.go b/mox-/rand.go index 3aac6d74d..d37153ba1 100644 --- a/mox-/rand.go +++ b/mox-/rand.go @@ -4,19 +4,23 @@ import ( cryptorand "crypto/rand" "encoding/binary" "fmt" - mathrand "math/rand" + mathrand2 "math/rand/v2" "sync" ) type rand struct { - rand *mathrand.Rand + rand *mathrand2.Rand sync.Mutex } // NewPseudoRand returns a new PRNG seeded with random bytes from crypto/rand. Its // functions can be called concurrently. func NewPseudoRand() *rand { - return &rand{rand: mathrand.New(mathrand.NewSource(CryptoRandInt()))} + var seed [32]byte + if _, err := cryptorand.Read(seed[:]); err != nil { + panic(err) + } + return &rand{rand: mathrand2.New(mathrand2.NewChaCha8(seed))} } func (r *rand) Float64() float64 { @@ -25,16 +29,10 @@ func (r *rand) Float64() float64 { return r.rand.Float64() } -func (r *rand) Intn(n int) int { - r.Lock() - defer r.Unlock() - return r.rand.Intn(n) -} - -func (r *rand) Read(buf []byte) (int, error) { +func (r *rand) IntN(n int) int { r.Lock() defer r.Unlock() - return r.rand.Read(buf) + return r.rand.IntN(n) } // CryptoRandInt returns a cryptographically random number. diff --git a/mtastsdb/refresh.go b/mtastsdb/refresh.go index d4c795062..8783aa93d 100644 --- a/mtastsdb/refresh.go +++ b/mtastsdb/refresh.go @@ -5,7 +5,7 @@ import ( "errors" "fmt" "log/slog" - mathrand "math/rand" + mathrand2 "math/rand/v2" "runtime/debug" "time" @@ -71,12 +71,11 @@ func refresh1(ctx context.Context, log mlog.Log, resolver dns.Resolver, sleep fu } // Randomize list. - rand := mathrand.New(mathrand.NewSource(time.Now().UnixNano())) for i := range prs { if i == 0 { continue } - j := rand.Intn(i + 1) + j := mathrand2.IntN(i + 1) prs[i], prs[j] = prs[j], prs[i] } @@ -87,7 +86,7 @@ func refresh1(ctx context.Context, log mlog.Log, resolver dns.Resolver, sleep fu go refreshDomain(ctx, log, DB, resolver, pr) if i < len(prs)-1 { interval := 3 * int64(time.Hour) / int64(len(prs)-1) - extra := time.Duration(rand.Int63n(interval) - interval/2) + extra := time.Duration(mathrand2.Int64N(interval) - interval/2) next := start.Add(time.Duration(int64(i+1)*interval) + extra) d := next.Sub(timeNow()) if d > 0 { diff --git a/queue/hook.go b/queue/hook.go index bc740eb9d..ec3728b70 100644 --- a/queue/hook.go +++ b/queue/hook.go @@ -1119,7 +1119,7 @@ func hookDeliver(log mlog.Log, h Hook) { } else { backoff = hookIntervals[len(hookIntervals)-1] * time.Duration(2) } - backoff += time.Duration(jitter.Intn(200)-100) * backoff / 10000 + backoff += time.Duration(jitter.IntN(200)-100) * backoff / 10000 h.Attempts++ now := time.Now() h.NextAttempt = now.Add(backoff) diff --git a/queue/queue.go b/queue/queue.go index 0a987becd..a8ce55047 100644 --- a/queue/queue.go +++ b/queue/queue.go @@ -1370,7 +1370,7 @@ func deliver(log mlog.Log, resolver dns.Resolver, m0 Msg) { return fmt.Errorf("get message to be delivered: %v", err) } - backoff = time.Duration(7*60+30+jitter.Intn(10)-5) * time.Second + backoff = time.Duration(7*60+30+jitter.IntN(10)-5) * time.Second for i := 0; i < m0.Attempts; i++ { backoff *= time.Duration(2) } diff --git a/tlsrptsend/send.go b/tlsrptsend/send.go index 69a05bd57..c7c34a642 100644 --- a/tlsrptsend/send.go +++ b/tlsrptsend/send.go @@ -73,7 +73,7 @@ var jitterRand = mox.NewPseudoRand() // Jitter so we don't cause load at exactly midnight, other processes may // already be doing that. var jitteredTimeUntil = func(t time.Time) time.Duration { - return time.Until(t.Add(time.Duration(240+jitterRand.Intn(120)) * time.Second)) + return time.Until(t.Add(time.Duration(240+jitterRand.IntN(120)) * time.Second)) } // Start launches a goroutine that wakes up just after 00:00 UTC to send TLSRPT diff --git a/webmail/eventwriter.go b/webmail/eventwriter.go index f9a87aae6..5d9a7f541 100644 --- a/webmail/eventwriter.go +++ b/webmail/eventwriter.go @@ -7,7 +7,7 @@ import ( "fmt" "io" "log/slog" - mathrand "math/rand" + mathrand2 "math/rand/v2" "net/http" "runtime/debug" "sync" @@ -71,9 +71,6 @@ func (ew *eventWriter) write(name string, v any) error { return ew.out.Flush() } -// For random wait between min and max delay. -var waitGen = mathrand.New(mathrand.NewSource(time.Now().UnixNano())) - // Schedule an event for writing to the connection. If events get a delay, this // function still returns immediately. func (ew *eventWriter) xsendEvent(ctx context.Context, log mlog.Log, name string, v any) { @@ -136,7 +133,7 @@ func (ew *eventWriter) xsendEvent(ctx context.Context, log mlog.Log, name string } // If we have an events channel, we have a goroutine that write the events, delayed. if ew.events != nil { - wait := ew.waitMin + time.Duration(waitGen.Intn(1000))*(ew.waitMax-ew.waitMin)/1000 + wait := ew.waitMin + time.Duration(mathrand2.IntN(1000))*(ew.waitMax-ew.waitMin)/1000 when := time.Now().Add(wait) ew.events <- struct { name string From 5f7831a7f0d462e62dfd46512719cd38ad3a9aeb Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Mon, 2 Dec 2024 22:03:18 +0100 Subject: [PATCH 13/20] move config-changing code from package mox-/ to admin/ needed for upcoming changes, where (now) package admin needs to import package store. before, because package store imports mox- (for accessing the active config), that would lead to a cyclic import. package mox- keeps its active config, package admin has the higher-level config-changing functions. --- {mox- => admin}/admin.go | 720 +++-------------------------------- admin/clientconfig.go | 168 ++++++++ admin/dnsrecords.go | 320 ++++++++++++++++ ctl.go | 23 +- http/autoconf.go | 22 +- http/mobileconfig.go | 8 +- localserve.go | 3 +- main.go | 11 +- mox-/config.go | 39 +- mox-/dir.go | 9 +- mox-/ip.go | 109 ++++++ mox-/txt.go | 24 ++ quickstart.go | 7 +- serve_unix.go | 4 +- {mox- => store}/lastknown.go | 7 +- webaccount/account.go | 21 +- webadmin/admin.go | 61 +-- webmail/api.go | 5 +- 18 files changed, 805 insertions(+), 756 deletions(-) rename {mox- => admin}/admin.go (57%) create mode 100644 admin/clientconfig.go create mode 100644 admin/dnsrecords.go create mode 100644 mox-/txt.go rename {mox- => store}/lastknown.go (88%) diff --git a/mox-/admin.go b/admin/admin.go similarity index 57% rename from mox-/admin.go rename to admin/admin.go index ac4dc7f55..17d5172b2 100644 --- a/mox-/admin.go +++ b/admin/admin.go @@ -1,67 +1,36 @@ -package mox +package admin import ( "bytes" "context" - "crypto" "crypto/ed25519" cryptorand "crypto/rand" "crypto/rsa" - "crypto/sha256" "crypto/x509" "encoding/pem" "errors" "fmt" "log/slog" - "net" - "net/url" "os" "path/filepath" "slices" - "sort" "strings" "time" "golang.org/x/exp/maps" - "github.com/mjl-/adns" - "github.com/mjl-/mox/config" - "github.com/mjl-/mox/dkim" - "github.com/mjl-/mox/dmarc" "github.com/mjl-/mox/dns" "github.com/mjl-/mox/junk" "github.com/mjl-/mox/mlog" + "github.com/mjl-/mox/mox-" "github.com/mjl-/mox/mtasts" "github.com/mjl-/mox/smtp" - "github.com/mjl-/mox/spf" - "github.com/mjl-/mox/tlsrpt" ) -var ErrRequest = errors.New("bad request") - -// TXTStrings returns a TXT record value as one or more quoted strings, each max -// 100 characters. In case of multiple strings, a multi-line record is returned. -func TXTStrings(s string) string { - if len(s) <= 100 { - return `"` + s + `"` - } +var pkglog = mlog.New("admin", nil) - r := "(\n" - for len(s) > 0 { - n := len(s) - if n > 100 { - n = 100 - } - if r != "" { - r += " " - } - r += "\t\t\"" + s[:n] + "\"\n" - s = s[n:] - } - r += "\t)" - return r -} +var ErrRequest = errors.New("bad request") // MakeDKIMEd25519Key returns a PEM buffer containing an ed25519 key for use // with DKIM. @@ -206,7 +175,7 @@ func MakeDomainConfig(ctx context.Context, domain, hostname dns.Domain, accountN addSelector := func(kind, name string, privKey []byte) error { record := fmt.Sprintf("%s._domainkey.%s", name, domain.ASCII) keyPath := filepath.Join("dkim", fmt.Sprintf("%s.%s.%s.privatekey.pkcs8.pem", record, timestamp, kind)) - p := configDirPath(ConfigDynamicPath, keyPath) + p := mox.ConfigDynamicDirPath(keyPath) if err := writeFile(log, p, privKey); err != nil { return err } @@ -323,10 +292,9 @@ func DKIMAdd(ctx context.Context, domain, selector dns.Domain, algorithm, hash s } // Only take lock now, we don't want to hold it while generating a key. - Conf.dynamicMutex.Lock() - defer Conf.dynamicMutex.Unlock() + defer mox.Conf.DynamicLockUnlock()() - c := Conf.Dynamic + c := mox.Conf.Dynamic d, ok := c.Domains[domain.Name()] if !ok { return fmt.Errorf("%w: domain does not exist", ErrRequest) @@ -339,7 +307,7 @@ func DKIMAdd(ctx context.Context, domain, selector dns.Domain, algorithm, hash s record := fmt.Sprintf("%s._domainkey.%s", selector.ASCII, domain.ASCII) timestamp := time.Now().Format("20060102T150405") keyPath := filepath.Join("dkim", fmt.Sprintf("%s.%s.%s.privatekey.pkcs8.pem", record, timestamp, kind)) - p := configDirPath(ConfigDynamicPath, keyPath) + p := mox.ConfigDynamicDirPath(keyPath) if err := writeFile(log, p, privKey); err != nil { return fmt.Errorf("writing key file: %v", err) } @@ -377,7 +345,7 @@ func DKIMAdd(ctx context.Context, domain, selector dns.Domain, algorithm, hash s } nc.Domains[domain.Name()] = nd - if err := writeDynamic(ctx, log, nc); err != nil { + if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil { return fmt.Errorf("writing domains.conf: %w", err) } @@ -397,10 +365,9 @@ func DKIMRemove(ctx context.Context, domain, selector dns.Domain) (rerr error) { } }() - Conf.dynamicMutex.Lock() - defer Conf.dynamicMutex.Unlock() + defer mox.Conf.DynamicLockUnlock()() - c := Conf.Dynamic + c := mox.Conf.Dynamic d, ok := c.Domains[domain.Name()] if !ok { return fmt.Errorf("%w: domain does not exist", ErrRequest) @@ -433,7 +400,7 @@ func DKIMRemove(ctx context.Context, domain, selector dns.Domain) (rerr error) { } nc.Domains[domain.Name()] = nd - if err := writeDynamic(ctx, log, nc); err != nil { + if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil { return fmt.Errorf("writing domains.conf: %w", err) } @@ -463,10 +430,9 @@ func DomainAdd(ctx context.Context, domain dns.Domain, accountName string, local } }() - Conf.dynamicMutex.Lock() - defer Conf.dynamicMutex.Unlock() + defer mox.Conf.DynamicLockUnlock()() - c := Conf.Dynamic + c := mox.Conf.Dynamic if _, ok := c.Domains[domain.Name()]; ok { return fmt.Errorf("%w: domain already present", ErrRequest) } @@ -481,14 +447,14 @@ func DomainAdd(ctx context.Context, domain dns.Domain, accountName string, local // Only enable mta-sts for domain if there is a listener with mta-sts. var withMTASTS bool - for _, l := range Conf.Static.Listeners { + for _, l := range mox.Conf.Static.Listeners { if l.MTASTSHTTPS.Enabled { withMTASTS = true break } } - confDomain, cleanupFiles, err := MakeDomainConfig(ctx, domain, Conf.Static.HostnameDomain, accountName, withMTASTS) + confDomain, cleanupFiles, err := MakeDomainConfig(ctx, domain, mox.Conf.Static.HostnameDomain, accountName, withMTASTS) if err != nil { return fmt.Errorf("preparing domain config: %v", err) } @@ -507,7 +473,7 @@ func DomainAdd(ctx context.Context, domain dns.Domain, accountName string, local return fmt.Errorf("%w: account name is empty", ErrRequest) } else if !ok { nc.Accounts[accountName] = MakeAccountConfig(smtp.NewAddress(localpart, domain)) - } else if accountName != Conf.Static.Postmaster.Account { + } else if accountName != mox.Conf.Static.Postmaster.Account { nacc := nc.Accounts[accountName] nd := map[string]config.Destination{} for k, v := range nacc.Destinations { @@ -521,7 +487,7 @@ func DomainAdd(ctx context.Context, domain dns.Domain, accountName string, local nc.Domains[domain.Name()] = confDomain - if err := writeDynamic(ctx, log, nc); err != nil { + if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil { return fmt.Errorf("writing domains.conf: %w", err) } log.Info("domain added", slog.Any("domain", domain)) @@ -540,10 +506,9 @@ func DomainRemove(ctx context.Context, domain dns.Domain) (rerr error) { } }() - Conf.dynamicMutex.Lock() - defer Conf.dynamicMutex.Unlock() + defer mox.Conf.DynamicLockUnlock()() - c := Conf.Dynamic + c := mox.Conf.Dynamic domConf, ok := c.Domains[domain.Name()] if !ok { return fmt.Errorf("%w: domain does not exist", ErrRequest) @@ -560,7 +525,7 @@ func DomainRemove(ctx context.Context, domain dns.Domain) (rerr error) { } } - if err := writeDynamic(ctx, log, nc); err != nil { + if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil { return fmt.Errorf("writing domains.conf: %w", err) } @@ -588,8 +553,8 @@ func moveAwayKeys(log mlog.Log, sels map[string]config.Selector, usedKeyPaths ma if sel.PrivateKeyFile == "" || usedKeyPaths[filepath.Clean(sel.PrivateKeyFile)] { continue } - src := ConfigDirPath(sel.PrivateKeyFile) - dst := ConfigDirPath(filepath.Join(filepath.Dir(sel.PrivateKeyFile), "old", filepath.Base(sel.PrivateKeyFile))) + src := mox.ConfigDirPath(sel.PrivateKeyFile) + dst := mox.ConfigDirPath(filepath.Join(filepath.Dir(sel.PrivateKeyFile), "old", filepath.Base(sel.PrivateKeyFile))) _, err := os.Stat(dst) if err == nil { err = fmt.Errorf("destination already exists") @@ -615,10 +580,9 @@ func DomainSave(ctx context.Context, domainName string, xmodify func(config *con } }() - Conf.dynamicMutex.Lock() - defer Conf.dynamicMutex.Unlock() + defer mox.Conf.DynamicLockUnlock()() - nc := Conf.Dynamic // Shallow copy. + nc := mox.Conf.Dynamic // Shallow copy. dom, ok := nc.Domains[domainName] // dom is a shallow copy. if !ok { return fmt.Errorf("%w: domain not present", ErrRequest) @@ -631,12 +595,12 @@ func DomainSave(ctx context.Context, domainName string, xmodify func(config *con // Compose new config without modifying existing data structures. If we fail, we // leave no trace. nc.Domains = map[string]config.Domain{} - for name, d := range Conf.Dynamic.Domains { + for name, d := range mox.Conf.Dynamic.Domains { nc.Domains[name] = d } nc.Domains[domainName] = dom - if err := writeDynamic(ctx, log, nc); err != nil { + if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil { return fmt.Errorf("writing domains.conf: %w", err) } @@ -656,13 +620,12 @@ func ConfigSave(ctx context.Context, xmodify func(config *config.Dynamic)) (rerr } }() - Conf.dynamicMutex.Lock() - defer Conf.dynamicMutex.Unlock() + defer mox.Conf.DynamicLockUnlock()() - nc := Conf.Dynamic // Shallow copy. + nc := mox.Conf.Dynamic // Shallow copy. xmodify(&nc) - if err := writeDynamic(ctx, log, nc); err != nil { + if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil { return fmt.Errorf("writing domains.conf: %w", err) } @@ -670,330 +633,6 @@ func ConfigSave(ctx context.Context, xmodify func(config *config.Dynamic)) (rerr return nil } -// DomainSPFIPs returns IPs to include in SPF records for domains. It includes the -// IPs on listeners that have SMTP enabled, and includes IPs configured for SOCKS -// transports. -func DomainSPFIPs() (ips []net.IP) { - for _, l := range Conf.Static.Listeners { - if !l.SMTP.Enabled || l.IPsNATed { - continue - } - ipstrs := l.IPs - if len(l.NATIPs) > 0 { - ipstrs = l.NATIPs - } - for _, ipstr := range ipstrs { - ip := net.ParseIP(ipstr) - if ip.IsUnspecified() { - continue - } - ips = append(ips, ip) - } - } - for _, t := range Conf.Static.Transports { - if t.Socks != nil { - ips = append(ips, t.Socks.IPs...) - } - } - return ips -} - -// todo: find a way to automatically create the dns records as it would greatly simplify setting up email for a domain. we could also dynamically make changes, e.g. providing grace periods after disabling a dkim key, only automatically removing the dkim dns key after a few days. but this requires some kind of api and authentication to the dns server. there doesn't appear to be a single commonly used api for dns management. each of the numerous cloud providers have their own APIs and rather large SKDs to use them. we don't want to link all of them in. - -// DomainRecords returns text lines describing DNS records required for configuring -// a domain. -// -// If certIssuerDomainName is set, CAA records to limit TLS certificate issuance to -// that caID will be suggested. If acmeAccountURI is also set, CAA records also -// restricting issuance to that account ID will be suggested. -func DomainRecords(domConf config.Domain, domain dns.Domain, hasDNSSEC bool, certIssuerDomainName, acmeAccountURI string) ([]string, error) { - d := domain.ASCII - h := Conf.Static.HostnameDomain.ASCII - - // The first line with ";" is used by ../testdata/integration/moxacmepebble.sh and - // ../testdata/integration/moxmail2.sh for selecting DNS records - records := []string{ - "; Time To Live of 5 minutes, may be recognized if importing as a zone file.", - "; Once your setup is working, you may want to increase the TTL.", - "$TTL 300", - "", - } - - if public, ok := Conf.Static.Listeners["public"]; ok && public.TLS != nil && (len(public.TLS.HostPrivateRSA2048Keys) > 0 || len(public.TLS.HostPrivateECDSAP256Keys) > 0) { - records = append(records, - `; DANE: These records indicate that a remote mail server trying to deliver email`, - `; with SMTP (TCP port 25) must verify the TLS certificate with DANE-EE (3), based`, - `; on the certificate public key ("SPKI", 1) that is SHA2-256-hashed (1) to the`, - `; hexadecimal hash. DANE-EE verification means only the certificate or public`, - `; key is verified, not whether the certificate is signed by a (centralized)`, - `; certificate authority (CA), is expired, or matches the host name.`, - `;`, - `; NOTE: Create the records below only once: They are for the machine, and apply`, - `; to all hosted domains.`, - ) - if !hasDNSSEC { - records = append(records, - ";", - "; WARNING: Domain does not appear to be DNSSEC-signed. To enable DANE, first", - "; enable DNSSEC on your domain, then add the TLSA records. Records below have been", - "; commented out.", - ) - } - addTLSA := func(privKey crypto.Signer) error { - spkiBuf, err := x509.MarshalPKIXPublicKey(privKey.Public()) - if err != nil { - return fmt.Errorf("marshal SubjectPublicKeyInfo for DANE record: %v", err) - } - sum := sha256.Sum256(spkiBuf) - tlsaRecord := adns.TLSA{ - Usage: adns.TLSAUsageDANEEE, - Selector: adns.TLSASelectorSPKI, - MatchType: adns.TLSAMatchTypeSHA256, - CertAssoc: sum[:], - } - var s string - if hasDNSSEC { - s = fmt.Sprintf("_25._tcp.%-*s TLSA %s", 20+len(d)-len("_25._tcp."), h+".", tlsaRecord.Record()) - } else { - s = fmt.Sprintf(";; _25._tcp.%-*s TLSA %s", 20+len(d)-len(";; _25._tcp."), h+".", tlsaRecord.Record()) - } - records = append(records, s) - return nil - } - for _, privKey := range public.TLS.HostPrivateECDSAP256Keys { - if err := addTLSA(privKey); err != nil { - return nil, err - } - } - for _, privKey := range public.TLS.HostPrivateRSA2048Keys { - if err := addTLSA(privKey); err != nil { - return nil, err - } - } - records = append(records, "") - } - - if d != h { - records = append(records, - "; For the machine, only needs to be created once, for the first domain added:", - "; ", - "; SPF-allow host for itself, resulting in relaxed DMARC pass for (postmaster)", - "; messages (DSNs) sent from host:", - fmt.Sprintf(`%-*s TXT "v=spf1 a -all"`, 20+len(d), h+"."), // ../rfc/7208:2263 ../rfc/7208:2287 - "", - ) - } - if d != h && Conf.Static.HostTLSRPT.ParsedLocalpart != "" { - uri := url.URL{ - Scheme: "mailto", - Opaque: smtp.NewAddress(Conf.Static.HostTLSRPT.ParsedLocalpart, Conf.Static.HostnameDomain).Pack(false), - } - tlsrptr := tlsrpt.Record{Version: "TLSRPTv1", RUAs: [][]tlsrpt.RUA{{tlsrpt.RUA(uri.String())}}} - records = append(records, - "; For the machine, only needs to be created once, for the first domain added:", - "; ", - "; Request reporting about success/failures of TLS connections to (MX) host, for DANE.", - fmt.Sprintf(`_smtp._tls.%-*s TXT "%s"`, 20+len(d)-len("_smtp._tls."), h+".", tlsrptr.String()), - "", - ) - } - - records = append(records, - "; Deliver email for the domain to this host.", - fmt.Sprintf("%s. MX 10 %s.", d, h), - "", - - "; Outgoing messages will be signed with the first two DKIM keys. The other two", - "; configured for backup, switching to them is just a config change.", - ) - var selectors []string - for name := range domConf.DKIM.Selectors { - selectors = append(selectors, name) - } - sort.Slice(selectors, func(i, j int) bool { - return selectors[i] < selectors[j] - }) - for _, name := range selectors { - sel := domConf.DKIM.Selectors[name] - dkimr := dkim.Record{ - Version: "DKIM1", - Hashes: []string{"sha256"}, - PublicKey: sel.Key.Public(), - } - if _, ok := sel.Key.(ed25519.PrivateKey); ok { - dkimr.Key = "ed25519" - } else if _, ok := sel.Key.(*rsa.PrivateKey); !ok { - return nil, fmt.Errorf("unrecognized private key for DKIM selector %q: %T", name, sel.Key) - } - txt, err := dkimr.Record() - if err != nil { - return nil, fmt.Errorf("making DKIM DNS TXT record: %v", err) - } - - if len(txt) > 100 { - records = append(records, - "; NOTE: The following is a single long record split over several lines for use", - "; in zone files. When adding through a DNS operator web interface, combine the", - "; strings into a single string, without ().", - ) - } - s := fmt.Sprintf("%s._domainkey.%s. TXT %s", name, d, TXTStrings(txt)) - records = append(records, s) - - } - dmarcr := dmarc.DefaultRecord - dmarcr.Policy = "reject" - if domConf.DMARC != nil { - uri := url.URL{ - Scheme: "mailto", - Opaque: smtp.NewAddress(domConf.DMARC.ParsedLocalpart, domConf.DMARC.DNSDomain).Pack(false), - } - dmarcr.AggregateReportAddresses = []dmarc.URI{ - {Address: uri.String(), MaxSize: 10, Unit: "m"}, - } - } - dspfr := spf.Record{Version: "spf1"} - for _, ip := range DomainSPFIPs() { - mech := "ip4" - if ip.To4() == nil { - mech = "ip6" - } - dspfr.Directives = append(dspfr.Directives, spf.Directive{Mechanism: mech, IP: ip}) - } - dspfr.Directives = append(dspfr.Directives, - spf.Directive{Mechanism: "mx"}, - spf.Directive{Qualifier: "~", Mechanism: "all"}, - ) - dspftxt, err := dspfr.Record() - if err != nil { - return nil, fmt.Errorf("making domain spf record: %v", err) - } - records = append(records, - "", - - "; Specify the MX host is allowed to send for our domain and for itself (for DSNs).", - "; ~all means softfail for anything else, which is done instead of -all to prevent older", - "; mail servers from rejecting the message because they never get to looking for a dkim/dmarc pass.", - fmt.Sprintf(`%s. TXT "%s"`, d, dspftxt), - "", - - "; Emails that fail the DMARC check (without aligned DKIM and without aligned SPF)", - "; should be rejected, and request reports. If you email through mailing lists that", - "; strip DKIM-Signature headers and don't rewrite the From header, you may want to", - "; set the policy to p=none.", - fmt.Sprintf(`_dmarc.%s. TXT "%s"`, d, dmarcr.String()), - "", - ) - - if sts := domConf.MTASTS; sts != nil { - records = append(records, - "; Remote servers can use MTA-STS to verify our TLS certificate with the", - "; WebPKI pool of CA's (certificate authorities) when delivering over SMTP with", - "; STARTTLSTLS.", - fmt.Sprintf(`mta-sts.%s. CNAME %s.`, d, h), - fmt.Sprintf(`_mta-sts.%s. TXT "v=STSv1; id=%s"`, d, sts.PolicyID), - "", - ) - } else { - records = append(records, - "; Note: No MTA-STS to indicate TLS should be used. Either because disabled for the", - "; domain or because mox.conf does not have a listener with MTA-STS configured.", - "", - ) - } - - if domConf.TLSRPT != nil { - uri := url.URL{ - Scheme: "mailto", - Opaque: smtp.NewAddress(domConf.TLSRPT.ParsedLocalpart, domConf.TLSRPT.DNSDomain).Pack(false), - } - tlsrptr := tlsrpt.Record{Version: "TLSRPTv1", RUAs: [][]tlsrpt.RUA{{tlsrpt.RUA(uri.String())}}} - records = append(records, - "; Request reporting about TLS failures.", - fmt.Sprintf(`_smtp._tls.%s. TXT "%s"`, d, tlsrptr.String()), - "", - ) - } - - if domConf.ClientSettingsDomain != "" && domConf.ClientSettingsDNSDomain != Conf.Static.HostnameDomain { - records = append(records, - "; Client settings will reference a subdomain of the hosted domain, making it", - "; easier to migrate to a different server in the future by not requiring settings", - "; in all clients to be updated.", - fmt.Sprintf(`%-*s CNAME %s.`, 20+len(d), domConf.ClientSettingsDNSDomain.ASCII+".", h), - "", - ) - } - - records = append(records, - "; Autoconfig is used by Thunderbird. Autodiscover is (in theory) used by Microsoft.", - fmt.Sprintf(`autoconfig.%s. CNAME %s.`, d, h), - fmt.Sprintf(`_autodiscover._tcp.%s. SRV 0 1 443 %s.`, d, h), - "", - - // ../rfc/6186:133 ../rfc/8314:692 - "; For secure IMAP and submission autoconfig, point to mail host.", - fmt.Sprintf(`_imaps._tcp.%s. SRV 0 1 993 %s.`, d, h), - fmt.Sprintf(`_submissions._tcp.%s. SRV 0 1 465 %s.`, d, h), - "", - // ../rfc/6186:242 - "; Next records specify POP3 and non-TLS ports are not to be used.", - "; These are optional and safe to leave out (e.g. if you have to click a lot in a", - "; DNS admin web interface).", - fmt.Sprintf(`_imap._tcp.%s. SRV 0 0 0 .`, d), - fmt.Sprintf(`_submission._tcp.%s. SRV 0 0 0 .`, d), - fmt.Sprintf(`_pop3._tcp.%s. SRV 0 0 0 .`, d), - fmt.Sprintf(`_pop3s._tcp.%s. SRV 0 0 0 .`, d), - ) - - if certIssuerDomainName != "" { - // ../rfc/8659:18 for CAA records. - records = append(records, - "", - "; Optional:", - "; You could mark Let's Encrypt as the only Certificate Authority allowed to", - "; sign TLS certificates for your domain.", - fmt.Sprintf(`%s. CAA 0 issue "%s"`, d, certIssuerDomainName), - ) - if acmeAccountURI != "" { - // ../rfc/8657:99 for accounturi. - // ../rfc/8657:147 for validationmethods. - records = append(records, - ";", - "; Optionally limit certificates for this domain to the account ID and methods used by mox.", - fmt.Sprintf(`;; %s. CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, d, certIssuerDomainName, acmeAccountURI), - ";", - "; Or alternatively only limit for email-specific subdomains, so you can use", - "; other accounts/methods for other subdomains.", - fmt.Sprintf(`;; autoconfig.%s. CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, d, certIssuerDomainName, acmeAccountURI), - fmt.Sprintf(`;; mta-sts.%s. CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, d, certIssuerDomainName, acmeAccountURI), - ) - if domConf.ClientSettingsDomain != "" && domConf.ClientSettingsDNSDomain != Conf.Static.HostnameDomain { - records = append(records, - fmt.Sprintf(`;; %-*s CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, 20-3+len(d), domConf.ClientSettingsDNSDomain.ASCII, certIssuerDomainName, acmeAccountURI), - ) - } - if strings.HasSuffix(h, "."+d) { - records = append(records, - ";", - "; And the mail hostname.", - fmt.Sprintf(`;; %-*s CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, 20-3+len(d), h+".", certIssuerDomainName, acmeAccountURI), - ) - } - } else { - // The string "will be suggested" is used by - // ../testdata/integration/moxacmepebble.sh and ../testdata/integration/moxmail2.sh - // as end of DNS records. - records = append(records, - ";", - "; Note: After starting up, once an ACME account has been created, CAA records", - "; that restrict issuance to the account will be suggested.", - ) - } - } - return records, nil -} - // AccountAdd adds an account and an initial address and reloads the configuration. // // The new account does not have a password, so cannot yet log in. Email can be @@ -1013,10 +652,9 @@ func AccountAdd(ctx context.Context, account, address string) (rerr error) { return fmt.Errorf("%w: parsing email address: %v", ErrRequest, err) } - Conf.dynamicMutex.Lock() - defer Conf.dynamicMutex.Unlock() + defer mox.Conf.DynamicLockUnlock()() - c := Conf.Dynamic + c := mox.Conf.Dynamic if _, ok := c.Accounts[account]; ok { return fmt.Errorf("%w: account already present", ErrRequest) } @@ -1034,7 +672,7 @@ func AccountAdd(ctx context.Context, account, address string) (rerr error) { } nc.Accounts[account] = MakeAccountConfig(addr) - if err := writeDynamic(ctx, log, nc); err != nil { + if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil { return fmt.Errorf("writing domains.conf: %w", err) } log.Info("account added", slog.String("account", account), slog.Any("address", addr)) @@ -1050,10 +688,9 @@ func AccountRemove(ctx context.Context, account string) (rerr error) { } }() - Conf.dynamicMutex.Lock() - defer Conf.dynamicMutex.Unlock() + defer mox.Conf.DynamicLockUnlock()() - c := Conf.Dynamic + c := mox.Conf.Dynamic if _, ok := c.Accounts[account]; !ok { return fmt.Errorf("%w: account does not exist", ErrRequest) } @@ -1068,12 +705,12 @@ func AccountRemove(ctx context.Context, account string) (rerr error) { } } - if err := writeDynamic(ctx, log, nc); err != nil { + if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil { return fmt.Errorf("writing domains.conf: %w", err) } - odir := filepath.Join(DataDirPath("accounts"), account) - tmpdir := filepath.Join(DataDirPath("tmp"), "oldaccount-"+account) + odir := filepath.Join(mox.DataDirPath("accounts"), account) + tmpdir := filepath.Join(mox.DataDirPath("tmp"), "oldaccount-"+account) if err := os.Rename(odir, tmpdir); err != nil { log.Errorx("moving old account data directory out of the way", err, slog.String("account", account)) return fmt.Errorf("account removed, but account data directory %q could not be moved out of the way: %v", odir, err) @@ -1093,12 +730,12 @@ func AccountRemove(ctx context.Context, account string) (rerr error) { // // Must be called with config lock held. func checkAddressAvailable(addr smtp.Address) error { - dc, ok := Conf.Dynamic.Domains[addr.Domain.Name()] + dc, ok := mox.Conf.Dynamic.Domains[addr.Domain.Name()] if !ok { return fmt.Errorf("domain does not exist") } - lp := CanonicalLocalpart(addr.Localpart, dc) - if _, ok := Conf.accountDestinations[smtp.NewAddress(lp, addr.Domain).String()]; ok { + lp := mox.CanonicalLocalpart(addr.Localpart, dc) + if _, ok := mox.Conf.AccountDestinationsLocked[smtp.NewAddress(lp, addr.Domain).String()]; ok { return fmt.Errorf("canonicalized address %s already configured", smtp.NewAddress(lp, addr.Domain)) } else if dc.LocalpartCatchallSeparator != "" && strings.Contains(string(addr.Localpart), dc.LocalpartCatchallSeparator) { return fmt.Errorf("localpart cannot include domain catchall separator %s", dc.LocalpartCatchallSeparator) @@ -1118,10 +755,9 @@ func AddressAdd(ctx context.Context, address, account string) (rerr error) { } }() - Conf.dynamicMutex.Lock() - defer Conf.dynamicMutex.Unlock() + defer mox.Conf.DynamicLockUnlock()() - c := Conf.Dynamic + c := mox.Conf.Dynamic a, ok := c.Accounts[account] if !ok { return fmt.Errorf("%w: account does not exist", ErrRequest) @@ -1135,9 +771,9 @@ func AddressAdd(ctx context.Context, address, account string) (rerr error) { } dname := d.Name() destAddr = "@" + dname - if _, ok := Conf.Dynamic.Domains[dname]; !ok { + if _, ok := mox.Conf.Dynamic.Domains[dname]; !ok { return fmt.Errorf("%w: domain does not exist", ErrRequest) - } else if _, ok := Conf.accountDestinations[destAddr]; ok { + } else if _, ok := mox.Conf.AccountDestinationsLocked[destAddr]; ok { return fmt.Errorf("%w: catchall address already configured for domain", ErrRequest) } } else { @@ -1167,7 +803,7 @@ func AddressAdd(ctx context.Context, address, account string) (rerr error) { a.Destinations = nd nc.Accounts[account] = a - if err := writeDynamic(ctx, log, nc); err != nil { + if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil { return fmt.Errorf("writing domains.conf: %w", err) } log.Info("address added", slog.String("address", address), slog.String("account", account)) @@ -1187,17 +823,16 @@ func AddressRemove(ctx context.Context, address string) (rerr error) { } }() - Conf.dynamicMutex.Lock() - defer Conf.dynamicMutex.Unlock() + defer mox.Conf.DynamicLockUnlock()() - ad, ok := Conf.accountDestinations[address] + ad, ok := mox.Conf.AccountDestinationsLocked[address] if !ok { return fmt.Errorf("%w: address does not exists", ErrRequest) } // Compose new config without modifying existing data structures. If we fail, we // leave no trace. - a, ok := Conf.Dynamic.Accounts[ad.Account] + a, ok := mox.Conf.Dynamic.Accounts[ad.Account] if !ok { return fmt.Errorf("internal error: cannot find account") } @@ -1241,12 +876,12 @@ func AddressRemove(ctx context.Context, address string) (rerr error) { if strings.HasPrefix(address, "@") { continue } - dc, ok := Conf.Dynamic.Domains[dom.Name()] + dc, ok := mox.Conf.Dynamic.Domains[dom.Name()] if !ok { return fmt.Errorf("%w: unknown domain in fromid login address %q", ErrRequest, fa.Pack(true)) } - flp := CanonicalLocalpart(fa.Localpart, dc) - alp := CanonicalLocalpart(pa.Localpart, dc) + flp := mox.CanonicalLocalpart(fa.Localpart, dc) + alp := mox.CanonicalLocalpart(pa.Localpart, dc) if alp != flp { // Keep for different localpart. fromIDLoginAddresses = append(fromIDLoginAddresses, a.FromIDLoginAddresses[i]) @@ -1255,7 +890,7 @@ func AddressRemove(ctx context.Context, address string) (rerr error) { na.FromIDLoginAddresses = fromIDLoginAddresses // And remove as member from aliases configured in domains. - domains := maps.Clone(Conf.Dynamic.Domains) + domains := maps.Clone(mox.Conf.Dynamic.Domains) for _, aa := range na.Aliases { if aa.SubscriptionAddress != address { continue @@ -1263,7 +898,7 @@ func AddressRemove(ctx context.Context, address string) (rerr error) { aliasAddr := fmt.Sprintf("%s@%s", aa.Alias.LocalpartStr, aa.Alias.Domain.Name()) - dom, ok := Conf.Dynamic.Domains[aa.Alias.Domain.Name()] + dom, ok := mox.Conf.Dynamic.Domains[aa.Alias.Domain.Name()] if !ok { return fmt.Errorf("cannot find domain for alias %s", aliasAddr) } @@ -1283,15 +918,15 @@ func AddressRemove(ctx context.Context, address string) (rerr error) { } na.Aliases = nil // Filled when parsing config. - nc := Conf.Dynamic + nc := mox.Conf.Dynamic nc.Accounts = map[string]config.Account{} - for name, a := range Conf.Dynamic.Accounts { + for name, a := range mox.Conf.Dynamic.Accounts { nc.Accounts[name] = a } nc.Accounts[ad.Account] = na nc.Domains = domains - if err := writeDynamic(ctx, log, nc); err != nil { + if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil { return fmt.Errorf("writing domains.conf: %w", err) } log.Info("address removed", slog.String("address", address), slog.String("account", ad.Account)) @@ -1393,10 +1028,9 @@ func AccountSave(ctx context.Context, account string, xmodify func(acc *config.A } }() - Conf.dynamicMutex.Lock() - defer Conf.dynamicMutex.Unlock() + defer mox.Conf.DynamicLockUnlock()() - c := Conf.Dynamic + c := mox.Conf.Dynamic acc, ok := c.Accounts[account] if !ok { return fmt.Errorf("%w: account not present", ErrRequest) @@ -1413,243 +1047,9 @@ func AccountSave(ctx context.Context, account string, xmodify func(acc *config.A } nc.Accounts[account] = acc - if err := writeDynamic(ctx, log, nc); err != nil { + if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil { return fmt.Errorf("writing domains.conf: %w", err) } log.Info("account fields saved", slog.String("account", account)) return nil } - -type TLSMode uint8 - -const ( - TLSModeImmediate TLSMode = 0 - TLSModeSTARTTLS TLSMode = 1 - TLSModeNone TLSMode = 2 -) - -type ProtocolConfig struct { - Host dns.Domain - Port int - TLSMode TLSMode -} - -type ClientConfig struct { - IMAP ProtocolConfig - Submission ProtocolConfig -} - -// ClientConfigDomain returns a single IMAP and Submission client configuration for -// a domain. -func ClientConfigDomain(d dns.Domain) (rconfig ClientConfig, rerr error) { - var haveIMAP, haveSubmission bool - - domConf, ok := Conf.Domain(d) - if !ok { - return ClientConfig{}, fmt.Errorf("%w: unknown domain", ErrRequest) - } - - gather := func(l config.Listener) (done bool) { - host := Conf.Static.HostnameDomain - if l.Hostname != "" { - host = l.HostnameDomain - } - if domConf.ClientSettingsDomain != "" { - host = domConf.ClientSettingsDNSDomain - } - if !haveIMAP && l.IMAPS.Enabled { - rconfig.IMAP.Host = host - rconfig.IMAP.Port = config.Port(l.IMAPS.Port, 993) - rconfig.IMAP.TLSMode = TLSModeImmediate - haveIMAP = true - } - if !haveIMAP && l.IMAP.Enabled { - rconfig.IMAP.Host = host - rconfig.IMAP.Port = config.Port(l.IMAP.Port, 143) - rconfig.IMAP.TLSMode = TLSModeSTARTTLS - if l.TLS == nil { - rconfig.IMAP.TLSMode = TLSModeNone - } - haveIMAP = true - } - if !haveSubmission && l.Submissions.Enabled { - rconfig.Submission.Host = host - rconfig.Submission.Port = config.Port(l.Submissions.Port, 465) - rconfig.Submission.TLSMode = TLSModeImmediate - haveSubmission = true - } - if !haveSubmission && l.Submission.Enabled { - rconfig.Submission.Host = host - rconfig.Submission.Port = config.Port(l.Submission.Port, 587) - rconfig.Submission.TLSMode = TLSModeSTARTTLS - if l.TLS == nil { - rconfig.Submission.TLSMode = TLSModeNone - } - haveSubmission = true - } - return haveIMAP && haveSubmission - } - - // Look at the public listener first. Most likely the intended configuration. - if public, ok := Conf.Static.Listeners["public"]; ok { - if gather(public) { - return - } - } - // Go through the other listeners in consistent order. - names := maps.Keys(Conf.Static.Listeners) - sort.Strings(names) - for _, name := range names { - if gather(Conf.Static.Listeners[name]) { - return - } - } - return ClientConfig{}, fmt.Errorf("%w: no listeners found for imap and/or submission", ErrRequest) -} - -// ClientConfigs holds the client configuration for IMAP/Submission for a -// domain. -type ClientConfigs struct { - Entries []ClientConfigsEntry -} - -type ClientConfigsEntry struct { - Protocol string - Host dns.Domain - Port int - Listener string - Note string -} - -// ClientConfigsDomain returns the client configs for IMAP/Submission for a -// domain. -func ClientConfigsDomain(d dns.Domain) (ClientConfigs, error) { - domConf, ok := Conf.Domain(d) - if !ok { - return ClientConfigs{}, fmt.Errorf("%w: unknown domain", ErrRequest) - } - - c := ClientConfigs{} - c.Entries = []ClientConfigsEntry{} - var listeners []string - - for name := range Conf.Static.Listeners { - listeners = append(listeners, name) - } - sort.Slice(listeners, func(i, j int) bool { - return listeners[i] < listeners[j] - }) - - note := func(tls bool, requiretls bool) string { - if !tls { - return "plain text, no STARTTLS configured" - } - if requiretls { - return "STARTTLS required" - } - return "STARTTLS optional" - } - - for _, name := range listeners { - l := Conf.Static.Listeners[name] - host := Conf.Static.HostnameDomain - if l.Hostname != "" { - host = l.HostnameDomain - } - if domConf.ClientSettingsDomain != "" { - host = domConf.ClientSettingsDNSDomain - } - if l.Submissions.Enabled { - c.Entries = append(c.Entries, ClientConfigsEntry{"Submission (SMTP)", host, config.Port(l.Submissions.Port, 465), name, "with TLS"}) - } - if l.IMAPS.Enabled { - c.Entries = append(c.Entries, ClientConfigsEntry{"IMAP", host, config.Port(l.IMAPS.Port, 993), name, "with TLS"}) - } - if l.Submission.Enabled { - c.Entries = append(c.Entries, ClientConfigsEntry{"Submission (SMTP)", host, config.Port(l.Submission.Port, 587), name, note(l.TLS != nil, !l.Submission.NoRequireSTARTTLS)}) - } - if l.IMAP.Enabled { - c.Entries = append(c.Entries, ClientConfigsEntry{"IMAP", host, config.Port(l.IMAPS.Port, 143), name, note(l.TLS != nil, !l.IMAP.NoRequireSTARTTLS)}) - } - } - - return c, nil -} - -// IPs returns ip addresses we may be listening/receiving mail on or -// connecting/sending from to the outside. -func IPs(ctx context.Context, receiveOnly bool) ([]net.IP, error) { - log := pkglog.WithContext(ctx) - - // Try to gather all IPs we are listening on by going through the config. - // If we encounter 0.0.0.0 or ::, we'll gather all local IPs afterwards. - var ips []net.IP - var ipv4all, ipv6all bool - for _, l := range Conf.Static.Listeners { - // If NATed, we don't know our external IPs. - if l.IPsNATed { - return nil, nil - } - check := l.IPs - if len(l.NATIPs) > 0 { - check = l.NATIPs - } - for _, s := range check { - ip := net.ParseIP(s) - if ip.IsUnspecified() { - if ip.To4() != nil { - ipv4all = true - } else { - ipv6all = true - } - continue - } - ips = append(ips, ip) - } - } - - // We'll list the IPs on the interfaces. How useful is this? There is a good chance - // we're listening on all addresses because of a load balancer/firewall. - if ipv4all || ipv6all { - ifaces, err := net.Interfaces() - if err != nil { - return nil, fmt.Errorf("listing network interfaces: %v", err) - } - for _, iface := range ifaces { - if iface.Flags&net.FlagUp == 0 { - continue - } - addrs, err := iface.Addrs() - if err != nil { - return nil, fmt.Errorf("listing addresses for network interface: %v", err) - } - if len(addrs) == 0 { - continue - } - - for _, addr := range addrs { - ip, _, err := net.ParseCIDR(addr.String()) - if err != nil { - log.Errorx("bad interface addr", err, slog.Any("address", addr)) - continue - } - v4 := ip.To4() != nil - if ipv4all && v4 || ipv6all && !v4 { - ips = append(ips, ip) - } - } - } - } - - if receiveOnly { - return ips, nil - } - - for _, t := range Conf.Static.Transports { - if t.Socks != nil { - ips = append(ips, t.Socks.IPs...) - } - } - - return ips, nil -} diff --git a/admin/clientconfig.go b/admin/clientconfig.go new file mode 100644 index 000000000..df789674e --- /dev/null +++ b/admin/clientconfig.go @@ -0,0 +1,168 @@ +package admin + +import ( + "fmt" + "sort" + + "golang.org/x/exp/maps" + + "github.com/mjl-/mox/config" + "github.com/mjl-/mox/dns" + "github.com/mjl-/mox/mox-" +) + +type TLSMode uint8 + +const ( + TLSModeImmediate TLSMode = 0 + TLSModeSTARTTLS TLSMode = 1 + TLSModeNone TLSMode = 2 +) + +type ProtocolConfig struct { + Host dns.Domain + Port int + TLSMode TLSMode +} + +type ClientConfig struct { + IMAP ProtocolConfig + Submission ProtocolConfig +} + +// ClientConfigDomain returns a single IMAP and Submission client configuration for +// a domain. +func ClientConfigDomain(d dns.Domain) (rconfig ClientConfig, rerr error) { + var haveIMAP, haveSubmission bool + + domConf, ok := mox.Conf.Domain(d) + if !ok { + return ClientConfig{}, fmt.Errorf("%w: unknown domain", ErrRequest) + } + + gather := func(l config.Listener) (done bool) { + host := mox.Conf.Static.HostnameDomain + if l.Hostname != "" { + host = l.HostnameDomain + } + if domConf.ClientSettingsDomain != "" { + host = domConf.ClientSettingsDNSDomain + } + if !haveIMAP && l.IMAPS.Enabled { + rconfig.IMAP.Host = host + rconfig.IMAP.Port = config.Port(l.IMAPS.Port, 993) + rconfig.IMAP.TLSMode = TLSModeImmediate + haveIMAP = true + } + if !haveIMAP && l.IMAP.Enabled { + rconfig.IMAP.Host = host + rconfig.IMAP.Port = config.Port(l.IMAP.Port, 143) + rconfig.IMAP.TLSMode = TLSModeSTARTTLS + if l.TLS == nil { + rconfig.IMAP.TLSMode = TLSModeNone + } + haveIMAP = true + } + if !haveSubmission && l.Submissions.Enabled { + rconfig.Submission.Host = host + rconfig.Submission.Port = config.Port(l.Submissions.Port, 465) + rconfig.Submission.TLSMode = TLSModeImmediate + haveSubmission = true + } + if !haveSubmission && l.Submission.Enabled { + rconfig.Submission.Host = host + rconfig.Submission.Port = config.Port(l.Submission.Port, 587) + rconfig.Submission.TLSMode = TLSModeSTARTTLS + if l.TLS == nil { + rconfig.Submission.TLSMode = TLSModeNone + } + haveSubmission = true + } + return haveIMAP && haveSubmission + } + + // Look at the public listener first. Most likely the intended configuration. + if public, ok := mox.Conf.Static.Listeners["public"]; ok { + if gather(public) { + return + } + } + // Go through the other listeners in consistent order. + names := maps.Keys(mox.Conf.Static.Listeners) + sort.Strings(names) + for _, name := range names { + if gather(mox.Conf.Static.Listeners[name]) { + return + } + } + return ClientConfig{}, fmt.Errorf("%w: no listeners found for imap and/or submission", ErrRequest) +} + +// ClientConfigs holds the client configuration for IMAP/Submission for a +// domain. +type ClientConfigs struct { + Entries []ClientConfigsEntry +} + +type ClientConfigsEntry struct { + Protocol string + Host dns.Domain + Port int + Listener string + Note string +} + +// ClientConfigsDomain returns the client configs for IMAP/Submission for a +// domain. +func ClientConfigsDomain(d dns.Domain) (ClientConfigs, error) { + domConf, ok := mox.Conf.Domain(d) + if !ok { + return ClientConfigs{}, fmt.Errorf("%w: unknown domain", ErrRequest) + } + + c := ClientConfigs{} + c.Entries = []ClientConfigsEntry{} + var listeners []string + + for name := range mox.Conf.Static.Listeners { + listeners = append(listeners, name) + } + sort.Slice(listeners, func(i, j int) bool { + return listeners[i] < listeners[j] + }) + + note := func(tls bool, requiretls bool) string { + if !tls { + return "plain text, no STARTTLS configured" + } + if requiretls { + return "STARTTLS required" + } + return "STARTTLS optional" + } + + for _, name := range listeners { + l := mox.Conf.Static.Listeners[name] + host := mox.Conf.Static.HostnameDomain + if l.Hostname != "" { + host = l.HostnameDomain + } + if domConf.ClientSettingsDomain != "" { + host = domConf.ClientSettingsDNSDomain + } + if l.Submissions.Enabled { + c.Entries = append(c.Entries, ClientConfigsEntry{"Submission (SMTP)", host, config.Port(l.Submissions.Port, 465), name, "with TLS"}) + } + if l.IMAPS.Enabled { + c.Entries = append(c.Entries, ClientConfigsEntry{"IMAP", host, config.Port(l.IMAPS.Port, 993), name, "with TLS"}) + } + if l.Submission.Enabled { + c.Entries = append(c.Entries, ClientConfigsEntry{"Submission (SMTP)", host, config.Port(l.Submission.Port, 587), name, note(l.TLS != nil, !l.Submission.NoRequireSTARTTLS)}) + } + if l.IMAP.Enabled { + c.Entries = append(c.Entries, ClientConfigsEntry{"IMAP", host, config.Port(l.IMAPS.Port, 143), name, note(l.TLS != nil, !l.IMAP.NoRequireSTARTTLS)}) + } + } + + return c, nil +} diff --git a/admin/dnsrecords.go b/admin/dnsrecords.go new file mode 100644 index 000000000..5c92b59bb --- /dev/null +++ b/admin/dnsrecords.go @@ -0,0 +1,320 @@ +package admin + +import ( + "crypto" + "crypto/ed25519" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "fmt" + "net/url" + "sort" + "strings" + + "github.com/mjl-/adns" + + "github.com/mjl-/mox/config" + "github.com/mjl-/mox/dkim" + "github.com/mjl-/mox/dmarc" + "github.com/mjl-/mox/dns" + "github.com/mjl-/mox/mox-" + "github.com/mjl-/mox/smtp" + "github.com/mjl-/mox/spf" + "github.com/mjl-/mox/tlsrpt" +) + +// todo: find a way to automatically create the dns records as it would greatly simplify setting up email for a domain. we could also dynamically make changes, e.g. providing grace periods after disabling a dkim key, only automatically removing the dkim dns key after a few days. but this requires some kind of api and authentication to the dns server. there doesn't appear to be a single commonly used api for dns management. each of the numerous cloud providers have their own APIs and rather large SKDs to use them. we don't want to link all of them in. + +// DomainRecords returns text lines describing DNS records required for configuring +// a domain. +// +// If certIssuerDomainName is set, CAA records to limit TLS certificate issuance to +// that caID will be suggested. If acmeAccountURI is also set, CAA records also +// restricting issuance to that account ID will be suggested. +func DomainRecords(domConf config.Domain, domain dns.Domain, hasDNSSEC bool, certIssuerDomainName, acmeAccountURI string) ([]string, error) { + d := domain.ASCII + h := mox.Conf.Static.HostnameDomain.ASCII + + // The first line with ";" is used by ../testdata/integration/moxacmepebble.sh and + // ../testdata/integration/moxmail2.sh for selecting DNS records + records := []string{ + "; Time To Live of 5 minutes, may be recognized if importing as a zone file.", + "; Once your setup is working, you may want to increase the TTL.", + "$TTL 300", + "", + } + + if public, ok := mox.Conf.Static.Listeners["public"]; ok && public.TLS != nil && (len(public.TLS.HostPrivateRSA2048Keys) > 0 || len(public.TLS.HostPrivateECDSAP256Keys) > 0) { + records = append(records, + `; DANE: These records indicate that a remote mail server trying to deliver email`, + `; with SMTP (TCP port 25) must verify the TLS certificate with DANE-EE (3), based`, + `; on the certificate public key ("SPKI", 1) that is SHA2-256-hashed (1) to the`, + `; hexadecimal hash. DANE-EE verification means only the certificate or public`, + `; key is verified, not whether the certificate is signed by a (centralized)`, + `; certificate authority (CA), is expired, or matches the host name.`, + `;`, + `; NOTE: Create the records below only once: They are for the machine, and apply`, + `; to all hosted domains.`, + ) + if !hasDNSSEC { + records = append(records, + ";", + "; WARNING: Domain does not appear to be DNSSEC-signed. To enable DANE, first", + "; enable DNSSEC on your domain, then add the TLSA records. Records below have been", + "; commented out.", + ) + } + addTLSA := func(privKey crypto.Signer) error { + spkiBuf, err := x509.MarshalPKIXPublicKey(privKey.Public()) + if err != nil { + return fmt.Errorf("marshal SubjectPublicKeyInfo for DANE record: %v", err) + } + sum := sha256.Sum256(spkiBuf) + tlsaRecord := adns.TLSA{ + Usage: adns.TLSAUsageDANEEE, + Selector: adns.TLSASelectorSPKI, + MatchType: adns.TLSAMatchTypeSHA256, + CertAssoc: sum[:], + } + var s string + if hasDNSSEC { + s = fmt.Sprintf("_25._tcp.%-*s TLSA %s", 20+len(d)-len("_25._tcp."), h+".", tlsaRecord.Record()) + } else { + s = fmt.Sprintf(";; _25._tcp.%-*s TLSA %s", 20+len(d)-len(";; _25._tcp."), h+".", tlsaRecord.Record()) + } + records = append(records, s) + return nil + } + for _, privKey := range public.TLS.HostPrivateECDSAP256Keys { + if err := addTLSA(privKey); err != nil { + return nil, err + } + } + for _, privKey := range public.TLS.HostPrivateRSA2048Keys { + if err := addTLSA(privKey); err != nil { + return nil, err + } + } + records = append(records, "") + } + + if d != h { + records = append(records, + "; For the machine, only needs to be created once, for the first domain added:", + "; ", + "; SPF-allow host for itself, resulting in relaxed DMARC pass for (postmaster)", + "; messages (DSNs) sent from host:", + fmt.Sprintf(`%-*s TXT "v=spf1 a -all"`, 20+len(d), h+"."), // ../rfc/7208:2263 ../rfc/7208:2287 + "", + ) + } + if d != h && mox.Conf.Static.HostTLSRPT.ParsedLocalpart != "" { + uri := url.URL{ + Scheme: "mailto", + Opaque: smtp.NewAddress(mox.Conf.Static.HostTLSRPT.ParsedLocalpart, mox.Conf.Static.HostnameDomain).Pack(false), + } + tlsrptr := tlsrpt.Record{Version: "TLSRPTv1", RUAs: [][]tlsrpt.RUA{{tlsrpt.RUA(uri.String())}}} + records = append(records, + "; For the machine, only needs to be created once, for the first domain added:", + "; ", + "; Request reporting about success/failures of TLS connections to (MX) host, for DANE.", + fmt.Sprintf(`_smtp._tls.%-*s TXT "%s"`, 20+len(d)-len("_smtp._tls."), h+".", tlsrptr.String()), + "", + ) + } + + records = append(records, + "; Deliver email for the domain to this host.", + fmt.Sprintf("%s. MX 10 %s.", d, h), + "", + + "; Outgoing messages will be signed with the first two DKIM keys. The other two", + "; configured for backup, switching to them is just a config change.", + ) + var selectors []string + for name := range domConf.DKIM.Selectors { + selectors = append(selectors, name) + } + sort.Slice(selectors, func(i, j int) bool { + return selectors[i] < selectors[j] + }) + for _, name := range selectors { + sel := domConf.DKIM.Selectors[name] + dkimr := dkim.Record{ + Version: "DKIM1", + Hashes: []string{"sha256"}, + PublicKey: sel.Key.Public(), + } + if _, ok := sel.Key.(ed25519.PrivateKey); ok { + dkimr.Key = "ed25519" + } else if _, ok := sel.Key.(*rsa.PrivateKey); !ok { + return nil, fmt.Errorf("unrecognized private key for DKIM selector %q: %T", name, sel.Key) + } + txt, err := dkimr.Record() + if err != nil { + return nil, fmt.Errorf("making DKIM DNS TXT record: %v", err) + } + + if len(txt) > 100 { + records = append(records, + "; NOTE: The following is a single long record split over several lines for use", + "; in zone files. When adding through a DNS operator web interface, combine the", + "; strings into a single string, without ().", + ) + } + s := fmt.Sprintf("%s._domainkey.%s. TXT %s", name, d, mox.TXTStrings(txt)) + records = append(records, s) + + } + dmarcr := dmarc.DefaultRecord + dmarcr.Policy = "reject" + if domConf.DMARC != nil { + uri := url.URL{ + Scheme: "mailto", + Opaque: smtp.NewAddress(domConf.DMARC.ParsedLocalpart, domConf.DMARC.DNSDomain).Pack(false), + } + dmarcr.AggregateReportAddresses = []dmarc.URI{ + {Address: uri.String(), MaxSize: 10, Unit: "m"}, + } + } + dspfr := spf.Record{Version: "spf1"} + for _, ip := range mox.DomainSPFIPs() { + mech := "ip4" + if ip.To4() == nil { + mech = "ip6" + } + dspfr.Directives = append(dspfr.Directives, spf.Directive{Mechanism: mech, IP: ip}) + } + dspfr.Directives = append(dspfr.Directives, + spf.Directive{Mechanism: "mx"}, + spf.Directive{Qualifier: "~", Mechanism: "all"}, + ) + dspftxt, err := dspfr.Record() + if err != nil { + return nil, fmt.Errorf("making domain spf record: %v", err) + } + records = append(records, + "", + + "; Specify the MX host is allowed to send for our domain and for itself (for DSNs).", + "; ~all means softfail for anything else, which is done instead of -all to prevent older", + "; mail servers from rejecting the message because they never get to looking for a dkim/dmarc pass.", + fmt.Sprintf(`%s. TXT "%s"`, d, dspftxt), + "", + + "; Emails that fail the DMARC check (without aligned DKIM and without aligned SPF)", + "; should be rejected, and request reports. If you email through mailing lists that", + "; strip DKIM-Signature headers and don't rewrite the From header, you may want to", + "; set the policy to p=none.", + fmt.Sprintf(`_dmarc.%s. TXT "%s"`, d, dmarcr.String()), + "", + ) + + if sts := domConf.MTASTS; sts != nil { + records = append(records, + "; Remote servers can use MTA-STS to verify our TLS certificate with the", + "; WebPKI pool of CA's (certificate authorities) when delivering over SMTP with", + "; STARTTLSTLS.", + fmt.Sprintf(`mta-sts.%s. CNAME %s.`, d, h), + fmt.Sprintf(`_mta-sts.%s. TXT "v=STSv1; id=%s"`, d, sts.PolicyID), + "", + ) + } else { + records = append(records, + "; Note: No MTA-STS to indicate TLS should be used. Either because disabled for the", + "; domain or because mox.conf does not have a listener with MTA-STS configured.", + "", + ) + } + + if domConf.TLSRPT != nil { + uri := url.URL{ + Scheme: "mailto", + Opaque: smtp.NewAddress(domConf.TLSRPT.ParsedLocalpart, domConf.TLSRPT.DNSDomain).Pack(false), + } + tlsrptr := tlsrpt.Record{Version: "TLSRPTv1", RUAs: [][]tlsrpt.RUA{{tlsrpt.RUA(uri.String())}}} + records = append(records, + "; Request reporting about TLS failures.", + fmt.Sprintf(`_smtp._tls.%s. TXT "%s"`, d, tlsrptr.String()), + "", + ) + } + + if domConf.ClientSettingsDomain != "" && domConf.ClientSettingsDNSDomain != mox.Conf.Static.HostnameDomain { + records = append(records, + "; Client settings will reference a subdomain of the hosted domain, making it", + "; easier to migrate to a different server in the future by not requiring settings", + "; in all clients to be updated.", + fmt.Sprintf(`%-*s CNAME %s.`, 20+len(d), domConf.ClientSettingsDNSDomain.ASCII+".", h), + "", + ) + } + + records = append(records, + "; Autoconfig is used by Thunderbird. Autodiscover is (in theory) used by Microsoft.", + fmt.Sprintf(`autoconfig.%s. CNAME %s.`, d, h), + fmt.Sprintf(`_autodiscover._tcp.%s. SRV 0 1 443 %s.`, d, h), + "", + + // ../rfc/6186:133 ../rfc/8314:692 + "; For secure IMAP and submission autoconfig, point to mail host.", + fmt.Sprintf(`_imaps._tcp.%s. SRV 0 1 993 %s.`, d, h), + fmt.Sprintf(`_submissions._tcp.%s. SRV 0 1 465 %s.`, d, h), + "", + // ../rfc/6186:242 + "; Next records specify POP3 and non-TLS ports are not to be used.", + "; These are optional and safe to leave out (e.g. if you have to click a lot in a", + "; DNS admin web interface).", + fmt.Sprintf(`_imap._tcp.%s. SRV 0 0 0 .`, d), + fmt.Sprintf(`_submission._tcp.%s. SRV 0 0 0 .`, d), + fmt.Sprintf(`_pop3._tcp.%s. SRV 0 0 0 .`, d), + fmt.Sprintf(`_pop3s._tcp.%s. SRV 0 0 0 .`, d), + ) + + if certIssuerDomainName != "" { + // ../rfc/8659:18 for CAA records. + records = append(records, + "", + "; Optional:", + "; You could mark Let's Encrypt as the only Certificate Authority allowed to", + "; sign TLS certificates for your domain.", + fmt.Sprintf(`%s. CAA 0 issue "%s"`, d, certIssuerDomainName), + ) + if acmeAccountURI != "" { + // ../rfc/8657:99 for accounturi. + // ../rfc/8657:147 for validationmethods. + records = append(records, + ";", + "; Optionally limit certificates for this domain to the account ID and methods used by mox.", + fmt.Sprintf(`;; %s. CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, d, certIssuerDomainName, acmeAccountURI), + ";", + "; Or alternatively only limit for email-specific subdomains, so you can use", + "; other accounts/methods for other subdomains.", + fmt.Sprintf(`;; autoconfig.%s. CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, d, certIssuerDomainName, acmeAccountURI), + fmt.Sprintf(`;; mta-sts.%s. CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, d, certIssuerDomainName, acmeAccountURI), + ) + if domConf.ClientSettingsDomain != "" && domConf.ClientSettingsDNSDomain != mox.Conf.Static.HostnameDomain { + records = append(records, + fmt.Sprintf(`;; %-*s CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, 20-3+len(d), domConf.ClientSettingsDNSDomain.ASCII, certIssuerDomainName, acmeAccountURI), + ) + } + if strings.HasSuffix(h, "."+d) { + records = append(records, + ";", + "; And the mail hostname.", + fmt.Sprintf(`;; %-*s CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, 20-3+len(d), h+".", certIssuerDomainName, acmeAccountURI), + ) + } + } else { + // The string "will be suggested" is used by + // ../testdata/integration/moxacmepebble.sh and ../testdata/integration/moxmail2.sh + // as end of DNS records. + records = append(records, + ";", + "; Note: After starting up, once an ACME account has been created, CAA records", + "; that restrict issuance to the account will be suggested.", + ) + } + } + return records, nil +} diff --git a/ctl.go b/ctl.go index 1722553e7..ed0018d59 100644 --- a/ctl.go +++ b/ctl.go @@ -21,6 +21,7 @@ import ( "github.com/mjl-/bstore" + "github.com/mjl-/mox/admin" "github.com/mjl-/mox/config" "github.com/mjl-/mox/dns" "github.com/mjl-/mox/message" @@ -973,7 +974,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) { localpart := ctl.xread() d, err := dns.ParseDomain(domain) ctl.xcheck(err, "parsing domain") - err = mox.DomainAdd(ctx, d, account, smtp.Localpart(localpart)) + err = admin.DomainAdd(ctx, d, account, smtp.Localpart(localpart)) ctl.xcheck(err, "adding domain") ctl.xwriteok() @@ -986,7 +987,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) { domain := ctl.xread() d, err := dns.ParseDomain(domain) ctl.xcheck(err, "parsing domain") - err = mox.DomainRemove(ctx, d) + err = admin.DomainRemove(ctx, d) ctl.xcheck(err, "removing domain") ctl.xwriteok() @@ -999,7 +1000,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) { */ account := ctl.xread() address := ctl.xread() - err := mox.AccountAdd(ctx, account, address) + err := admin.AccountAdd(ctx, account, address) ctl.xcheck(err, "adding account") ctl.xwriteok() @@ -1010,7 +1011,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) { < "ok" or error */ account := ctl.xread() - err := mox.AccountRemove(ctx, account) + err := admin.AccountRemove(ctx, account) ctl.xcheck(err, "removing account") ctl.xwriteok() @@ -1023,7 +1024,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) { */ address := ctl.xread() account := ctl.xread() - err := mox.AddressAdd(ctx, address, account) + err := admin.AddressAdd(ctx, address, account) ctl.xcheck(err, "adding address") ctl.xwriteok() @@ -1034,7 +1035,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) { < "ok" or error */ address := ctl.xread() - err := mox.AddressRemove(ctx, address) + err := admin.AddressRemove(ctx, address) ctl.xcheck(err, "removing address") ctl.xwriteok() @@ -1099,7 +1100,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) { ctl.xcheck(err, "parsing address") var alias config.Alias xparseJSON(ctl, line, &alias) - err = mox.AliasAdd(ctx, addr, alias) + err = admin.AliasAdd(ctx, addr, alias) ctl.xcheck(err, "adding alias") ctl.xwriteok() @@ -1118,7 +1119,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) { allowmsgfrom := ctl.xread() addr, err := smtp.ParseAddress(address) ctl.xcheck(err, "parsing address") - err = mox.DomainSave(ctx, addr.Domain.Name(), func(d *config.Domain) error { + err = admin.DomainSave(ctx, addr.Domain.Name(), func(d *config.Domain) error { a, ok := d.Aliases[addr.Localpart.String()] if !ok { return fmt.Errorf("alias does not exist") @@ -1159,7 +1160,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) { address := ctl.xread() addr, err := smtp.ParseAddress(address) ctl.xcheck(err, "parsing address") - err = mox.AliasRemove(ctx, addr) + err = admin.AliasRemove(ctx, addr) ctl.xcheck(err, "removing alias") ctl.xwriteok() @@ -1176,7 +1177,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) { ctl.xcheck(err, "parsing address") var addresses []string xparseJSON(ctl, line, &addresses) - err = mox.AliasAddressesAdd(ctx, addr, addresses) + err = admin.AliasAddressesAdd(ctx, addr, addresses) ctl.xcheck(err, "adding addresses to alias") ctl.xwriteok() @@ -1193,7 +1194,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) { ctl.xcheck(err, "parsing address") var addresses []string xparseJSON(ctl, line, &addresses) - err = mox.AliasAddressesRemove(ctx, addr, addresses) + err = admin.AliasAddressesRemove(ctx, addr, addresses) ctl.xcheck(err, "removing addresses to alias") ctl.xwriteok() diff --git a/http/autoconf.go b/http/autoconf.go index 3c877e44f..383779d3c 100644 --- a/http/autoconf.go +++ b/http/autoconf.go @@ -11,7 +11,7 @@ import ( "github.com/prometheus/client_golang/prometheus/promauto" "rsc.io/qr" - "github.com/mjl-/mox/mox-" + "github.com/mjl-/mox/admin" "github.com/mjl-/mox/smtp" ) @@ -70,13 +70,13 @@ func autoconfHandle(w http.ResponseWriter, r *http.Request) { return } - socketType := func(tlsMode mox.TLSMode) (string, error) { + socketType := func(tlsMode admin.TLSMode) (string, error) { switch tlsMode { - case mox.TLSModeImmediate: + case admin.TLSModeImmediate: return "SSL", nil - case mox.TLSModeSTARTTLS: + case admin.TLSModeSTARTTLS: return "STARTTLS", nil - case mox.TLSModeNone: + case admin.TLSModeNone: return "plain", nil default: return "", fmt.Errorf("unknown tls mode %v", tlsMode) @@ -84,7 +84,7 @@ func autoconfHandle(w http.ResponseWriter, r *http.Request) { } var imapTLS, submissionTLS string - config, err := mox.ClientConfigDomain(addr.Domain) + config, err := admin.ClientConfigDomain(addr.Domain) if err == nil { imapTLS, err = socketType(config.IMAP.TLSMode) } @@ -170,13 +170,13 @@ func autodiscoverHandle(w http.ResponseWriter, r *http.Request) { } // tlsmode returns the "ssl" and "encryption" fields. - tlsmode := func(tlsMode mox.TLSMode) (string, string, error) { + tlsmode := func(tlsMode admin.TLSMode) (string, string, error) { switch tlsMode { - case mox.TLSModeImmediate: + case admin.TLSModeImmediate: return "on", "TLS", nil - case mox.TLSModeSTARTTLS: + case admin.TLSModeSTARTTLS: return "on", "", nil - case mox.TLSModeNone: + case admin.TLSModeNone: return "off", "", nil default: return "", "", fmt.Errorf("unknown tls mode %v", tlsMode) @@ -185,7 +185,7 @@ func autodiscoverHandle(w http.ResponseWriter, r *http.Request) { var imapSSL, imapEncryption string var submissionSSL, submissionEncryption string - config, err := mox.ClientConfigDomain(addr.Domain) + config, err := admin.ClientConfigDomain(addr.Domain) if err == nil { imapSSL, imapEncryption, err = tlsmode(config.IMAP.TLSMode) } diff --git a/http/mobileconfig.go b/http/mobileconfig.go index 373573ef5..af29fa6c4 100644 --- a/http/mobileconfig.go +++ b/http/mobileconfig.go @@ -12,7 +12,7 @@ import ( "golang.org/x/exp/maps" - "github.com/mjl-/mox/mox-" + "github.com/mjl-/mox/admin" "github.com/mjl-/mox/smtp" ) @@ -122,7 +122,7 @@ func MobileConfig(addresses []string, fullName string) ([]byte, error) { return nil, fmt.Errorf("parsing address: %v", err) } - config, err := mox.ClientConfigDomain(addr.Domain) + config, err := admin.ClientConfigDomain(addr.Domain) if err != nil { return nil, fmt.Errorf("getting config for domain: %v", err) } @@ -175,12 +175,12 @@ func MobileConfig(addresses []string, fullName string) ([]byte, error) { "IncomingMailServerUsername": addresses[0], "IncomingMailServerHostName": config.IMAP.Host.ASCII, "IncomingMailServerPortNumber": config.IMAP.Port, - "IncomingMailServerUseSSL": config.IMAP.TLSMode == mox.TLSModeImmediate, + "IncomingMailServerUseSSL": config.IMAP.TLSMode == admin.TLSModeImmediate, "OutgoingMailServerAuthentication": "EmailAuthCRAMMD5", // SCRAM not an option at time of writing... "OutgoingMailServerHostName": config.Submission.Host.ASCII, "OutgoingMailServerPortNumber": config.Submission.Port, "OutgoingMailServerUsername": addresses[0], - "OutgoingMailServerUseSSL": config.Submission.TLSMode == mox.TLSModeImmediate, + "OutgoingMailServerUseSSL": config.Submission.TLSMode == admin.TLSModeImmediate, "OutgoingPasswordSameAsIncomingPassword": true, "PayloadIdentifier": reverseAddr + ".email.account", "PayloadType": "com.apple.mail.managed", diff --git a/localserve.go b/localserve.go index 0a745453c..48f65f1cd 100644 --- a/localserve.go +++ b/localserve.go @@ -25,6 +25,7 @@ import ( "github.com/mjl-/sconf" + "github.com/mjl-/mox/admin" "github.com/mjl-/mox/config" "github.com/mjl-/mox/dkim" "github.com/mjl-/mox/dns" @@ -421,7 +422,7 @@ func writeLocalConfig(log mlog.Log, dir, ip string) (rerr error) { }, } - dkimKeyBuf, err := mox.MakeDKIMEd25519Key(dns.Domain{ASCII: "localserve"}, dns.Domain{ASCII: "localhost"}) + dkimKeyBuf, err := admin.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) diff --git a/main.go b/main.go index 38b4d144d..b68249f20 100644 --- a/main.go +++ b/main.go @@ -45,6 +45,7 @@ import ( "github.com/mjl-/sconf" "github.com/mjl-/sherpa" + "github.com/mjl-/mox/admin" "github.com/mjl-/mox/config" "github.com/mjl-/mox/dane" "github.com/mjl-/mox/dkim" @@ -570,7 +571,7 @@ configured over otherwise secured connections, like a VPN. } func printClientConfig(d dns.Domain) { - cc, err := mox.ClientConfigsDomain(d) + cc, err := admin.ClientConfigsDomain(d) xcheckf(err, "getting client config") fmt.Printf("%-20s %-30s %5s %-15s %s\n", "Protocol", "Host", "Port", "Listener", "Note") for _, e := range cc.Entries { @@ -1006,7 +1007,7 @@ configured. } } - records, err := mox.DomainRecords(domConf, d, result.Authentic, certIssuerDomainName, acmeAccountURI) + records, err := admin.DomainRecords(domConf, d, result.Authentic, certIssuerDomainName, acmeAccountURI) xcheckf(err, "records") fmt.Print(strings.Join(records, "\n") + "\n") } @@ -1539,7 +1540,7 @@ with DKIM, by mox. c.Usage() } - buf, err := mox.MakeDKIMRSAKey(dns.Domain{}, dns.Domain{}) + buf, err := admin.MakeDKIMRSAKey(dns.Domain{}, dns.Domain{}) xcheckf(err, "making rsa private key") _, err = os.Stdout.Write(buf) xcheckf(err, "writing rsa private key") @@ -2077,7 +2078,7 @@ so it is recommended to sign messages with both RSA and ed25519 keys. c.Usage() } - buf, err := mox.MakeDKIMEd25519Key(dns.Domain{}, dns.Domain{}) + buf, err := admin.MakeDKIMEd25519Key(dns.Domain{}, dns.Domain{}) xcheckf(err, "making dkim ed25519 key") _, err = os.Stdout.Write(buf) xcheckf(err, "writing dkim ed25519 key") @@ -2786,7 +2787,7 @@ printed. } mustLoadConfig() - current, lastknown, _, err := mox.LastKnown() + current, lastknown, _, err := store.LastKnown() if err != nil { log.Printf("getting last known version: %s", err) } else { diff --git a/mox-/config.go b/mox-/config.go index a238f0786..1abd00c72 100644 --- a/mox-/config.go +++ b/mox-/config.go @@ -86,11 +86,13 @@ type Config struct { Dynamic config.Dynamic // Can only be accessed directly by tests. Use methods on Config for locked access. dynamicMtime time.Time DynamicLastCheck time.Time // For use by quickstart only to skip checks. + // From canonical full address (localpart@domain, lower-cased when // case-insensitive, stripped of catchall separator) to account and address. - // Domains are IDNA names in utf8. - accountDestinations map[string]AccountDestination - // Like accountDestinations, but for aliases. + // Domains are IDNA names in utf8. Dynamic config lock must be held when accessing. + AccountDestinationsLocked map[string]AccountDestination + + // Like AccountDestinationsLocked, but for aliases. aliases map[string]config.Alias } @@ -142,9 +144,11 @@ func (c *Config) LogLevels() map[string]slog.Level { return c.copyLogLevels() } -func (c *Config) withDynamicLock(fn func()) { +// DynamicLockUnlock locks the dynamic config, will try updating the latest state +// from disk, and return an unlock function. Should be called as "defer +// Conf.DynamicLockUnlock()()". +func (c *Config) DynamicLockUnlock() func() { c.dynamicMutex.Lock() - defer c.dynamicMutex.Unlock() now := time.Now() if now.Sub(c.DynamicLastCheck) > time.Second { c.DynamicLastCheck = now @@ -159,6 +163,11 @@ func (c *Config) withDynamicLock(fn func()) { } } } + return c.dynamicMutex.Unlock +} + +func (c *Config) withDynamicLock(fn func()) { + defer c.DynamicLockUnlock()() fn() } @@ -170,7 +179,7 @@ func (c *Config) loadDynamic() []error { } c.Dynamic = d c.dynamicMtime = mtime - c.accountDestinations = accDests + c.AccountDestinationsLocked = accDests c.aliases = aliases c.allowACMEHosts(pkglog, true) return nil @@ -213,7 +222,7 @@ func (c *Config) DomainLocalparts(d dns.Domain) (map[string]string, map[string]c m := map[string]string{} aliases := map[string]config.Alias{} c.withDynamicLock(func() { - for addr, ad := range c.accountDestinations { + for addr, ad := range c.AccountDestinationsLocked { if strings.HasSuffix(addr, suffix) { if ad.Catchall { m[""] = ad.Account @@ -247,7 +256,7 @@ func (c *Config) Account(name string) (acc config.Account, ok bool) { func (c *Config) AccountDestination(addr string) (accDest AccountDestination, alias *config.Alias, ok bool) { c.withDynamicLock(func() { - accDest, ok = c.accountDestinations[addr] + accDest, ok = c.AccountDestinationsLocked[addr] if !ok { var a config.Alias a, ok = c.aliases[addr] @@ -345,9 +354,13 @@ func (c *Config) allowACMEHosts(log mlog.Log, checkACMEHosts bool) { // todo future: write config parsing & writing code that can read a config and remembers the exact tokens including newlines and comments, and can write back a modified file. the goal is to be able to write a config file automatically (after changing fields through the ui), but not loose comments and whitespace, to still get useful diffs for storing the config in a version control system. -// must be called with lock held. +// WriteDynamicLocked prepares an updated internal state for the new dynamic +// config, then writes it to disk and activates it. +// // Returns ErrConfig if the configuration is not valid. -func writeDynamic(ctx context.Context, log mlog.Log, c config.Dynamic) error { +// +// Must be called with config lock held. +func WriteDynamicLocked(ctx context.Context, log mlog.Log, c config.Dynamic) error { accDests, aliases, errs := prepareDynamicConfig(ctx, log, ConfigDynamicPath, Conf.Static, &c) if len(errs) > 0 { errstrs := make([]string, len(errs)) @@ -399,7 +412,7 @@ func writeDynamic(ctx context.Context, log mlog.Log, c config.Dynamic) error { Conf.dynamicMtime = fi.ModTime() Conf.DynamicLastCheck = time.Now() Conf.Dynamic = c - Conf.accountDestinations = accDests + Conf.AccountDestinationsLocked = accDests Conf.aliases = aliases Conf.allowACMEHosts(log, true) @@ -440,7 +453,7 @@ func LoadConfig(ctx context.Context, log mlog.Log, doLoadTLSKeyCerts, checkACMEH // SetConfig sets a new config. Not to be used during normal operation. func SetConfig(c *Config) { // Cannot just assign *c to Conf, it would copy the mutex. - Conf = Config{c.Static, sync.Mutex{}, c.Log, sync.Mutex{}, c.Dynamic, c.dynamicMtime, c.DynamicLastCheck, c.accountDestinations, c.aliases} + Conf = Config{c.Static, sync.Mutex{}, c.Log, sync.Mutex{}, c.Dynamic, c.dynamicMtime, c.DynamicLastCheck, c.AccountDestinationsLocked, c.aliases} // If we have non-standard CA roots, use them for all HTTPS requests. if Conf.Static.TLS.CertPool != nil { @@ -491,7 +504,7 @@ func ParseConfig(ctx context.Context, log mlog.Log, p string, checkOnly, doLoadT } pp := filepath.Join(filepath.Dir(p), "domains.conf") - c.Dynamic, c.dynamicMtime, c.accountDestinations, c.aliases, errs = ParseDynamicConfig(ctx, log, pp, c.Static) + c.Dynamic, c.dynamicMtime, c.AccountDestinationsLocked, c.aliases, errs = ParseDynamicConfig(ctx, log, pp, c.Static) if !checkOnly { c.allowACMEHosts(log, checkACMEHosts) diff --git a/mox-/dir.go b/mox-/dir.go index 792b67324..7ea3e5277 100644 --- a/mox-/dir.go +++ b/mox-/dir.go @@ -5,11 +5,18 @@ import ( ) // ConfigDirPath returns the path to "f". Either f itself when absolute, or -// interpreted relative to the directory of the current config file. +// interpreted relative to the directory of the static configuration file +// (mox.conf). func ConfigDirPath(f string) string { return configDirPath(ConfigStaticPath, f) } +// Like ConfigDirPath, but relative paths are interpreted relative to the directory +// of the dynamic configuration file (domains.conf). +func ConfigDynamicDirPath(f string) string { + return configDirPath(ConfigDynamicPath, f) +} + // DataDirPath returns to the path to "f". Either f itself when absolute, or // interpreted relative to the data directory from the currently active // configuration. diff --git a/mox-/ip.go b/mox-/ip.go index 116322b3f..c5d9f2810 100644 --- a/mox-/ip.go +++ b/mox-/ip.go @@ -1,6 +1,9 @@ package mox import ( + "context" + "fmt" + "log/slog" "net" ) @@ -19,3 +22,109 @@ func Network(ip string) string { } return "tcp6" } + +// DomainSPFIPs returns IPs to include in SPF records for domains. It includes the +// IPs on listeners that have SMTP enabled, and includes IPs configured for SOCKS +// transports. +func DomainSPFIPs() (ips []net.IP) { + for _, l := range Conf.Static.Listeners { + if !l.SMTP.Enabled || l.IPsNATed { + continue + } + ipstrs := l.IPs + if len(l.NATIPs) > 0 { + ipstrs = l.NATIPs + } + for _, ipstr := range ipstrs { + ip := net.ParseIP(ipstr) + if ip.IsUnspecified() { + continue + } + ips = append(ips, ip) + } + } + for _, t := range Conf.Static.Transports { + if t.Socks != nil { + ips = append(ips, t.Socks.IPs...) + } + } + return ips +} + +// IPs returns ip addresses we may be listening/receiving mail on or +// connecting/sending from to the outside. +func IPs(ctx context.Context, receiveOnly bool) ([]net.IP, error) { + log := pkglog.WithContext(ctx) + + // Try to gather all IPs we are listening on by going through the config. + // If we encounter 0.0.0.0 or ::, we'll gather all local IPs afterwards. + var ips []net.IP + var ipv4all, ipv6all bool + for _, l := range Conf.Static.Listeners { + // If NATed, we don't know our external IPs. + if l.IPsNATed { + return nil, nil + } + check := l.IPs + if len(l.NATIPs) > 0 { + check = l.NATIPs + } + for _, s := range check { + ip := net.ParseIP(s) + if ip.IsUnspecified() { + if ip.To4() != nil { + ipv4all = true + } else { + ipv6all = true + } + continue + } + ips = append(ips, ip) + } + } + + // We'll list the IPs on the interfaces. How useful is this? There is a good chance + // we're listening on all addresses because of a load balancer/firewall. + if ipv4all || ipv6all { + ifaces, err := net.Interfaces() + if err != nil { + return nil, fmt.Errorf("listing network interfaces: %v", err) + } + for _, iface := range ifaces { + if iface.Flags&net.FlagUp == 0 { + continue + } + addrs, err := iface.Addrs() + if err != nil { + return nil, fmt.Errorf("listing addresses for network interface: %v", err) + } + if len(addrs) == 0 { + continue + } + + for _, addr := range addrs { + ip, _, err := net.ParseCIDR(addr.String()) + if err != nil { + log.Errorx("bad interface addr", err, slog.Any("address", addr)) + continue + } + v4 := ip.To4() != nil + if ipv4all && v4 || ipv6all && !v4 { + ips = append(ips, ip) + } + } + } + } + + if receiveOnly { + return ips, nil + } + + for _, t := range Conf.Static.Transports { + if t.Socks != nil { + ips = append(ips, t.Socks.IPs...) + } + } + + return ips, nil +} diff --git a/mox-/txt.go b/mox-/txt.go new file mode 100644 index 000000000..47bc173b5 --- /dev/null +++ b/mox-/txt.go @@ -0,0 +1,24 @@ +package mox + +// TXTStrings returns a TXT record value as one or more quoted strings, each max +// 100 characters. In case of multiple strings, a multi-line record is returned. +func TXTStrings(s string) string { + if len(s) <= 100 { + return `"` + s + `"` + } + + r := "(\n" + for len(s) > 0 { + n := len(s) + if n > 100 { + n = 100 + } + if r != "" { + r += " " + } + r += "\t\t\"" + s[:n] + "\"\n" + s = s[n:] + } + r += "\t)" + return r +} diff --git a/quickstart.go b/quickstart.go index 774003099..4dc7e2614 100644 --- a/quickstart.go +++ b/quickstart.go @@ -28,6 +28,7 @@ import ( "github.com/mjl-/sconf" + "github.com/mjl-/mox/admin" "github.com/mjl-/mox/config" "github.com/mjl-/mox/dns" "github.com/mjl-/mox/dnsbl" @@ -827,9 +828,9 @@ and check the admin page for the needed DNS records.`) mox.Conf.DynamicLastCheck = time.Now() // Prevent error logging by Make calls below. - accountConf := mox.MakeAccountConfig(addr) + accountConf := admin.MakeAccountConfig(addr) const withMTASTS = true - confDomain, keyPaths, err := mox.MakeDomainConfig(context.Background(), domain, dnshostname, accountName, withMTASTS) + confDomain, keyPaths, err := admin.MakeDomainConfig(context.Background(), domain, dnshostname, accountName, withMTASTS) if err != nil { fatalf("making domain config: %s", err) } @@ -989,7 +990,7 @@ have been configured correctly. The DNS records to add: // priming dns caches with negative/absent records, causing our "quick setup" to // appear to fail or take longer than "quick". - records, err := mox.DomainRecords(confDomain, domain, domainDNSSECResult.Authentic, "letsencrypt.org", "") + records, err := admin.DomainRecords(confDomain, domain, domainDNSSECResult.Authentic, "letsencrypt.org", "") if err != nil { fatalf("making required DNS records") } diff --git a/serve_unix.go b/serve_unix.go index 1d5e772bf..280e72e02 100644 --- a/serve_unix.go +++ b/serve_unix.go @@ -266,7 +266,7 @@ Only implemented on unix systems, not Windows. if mox.Conf.Static.CheckUpdates { checkUpdates := func() time.Duration { next := 24 * time.Hour - current, lastknown, mtime, err := mox.LastKnown() + current, lastknown, mtime, err := store.LastKnown() if err != nil { log.Infox("determining own version before checking for updates, trying again in 24h", err) return next @@ -350,7 +350,7 @@ Only implemented on unix systems, not Windows. slog.Any("current", current), slog.Any("lastknown", lastknown), slog.Any("latest", latest)) - if err := mox.StoreLastKnown(latest); err != nil { + if err := store.StoreLastKnown(latest); err != nil { // This will be awkward, we'll keep notifying the postmaster once every 24h... log.Infox("updating last known version", err) } diff --git a/mox-/lastknown.go b/store/lastknown.go similarity index 88% rename from mox-/lastknown.go rename to store/lastknown.go index 1cf9c3543..ef8a1b848 100644 --- a/mox-/lastknown.go +++ b/store/lastknown.go @@ -1,4 +1,4 @@ -package mox +package store import ( "fmt" @@ -6,6 +6,7 @@ import ( "strings" "time" + "github.com/mjl-/mox/mox-" "github.com/mjl-/mox/moxvar" "github.com/mjl-/mox/updates" ) @@ -13,7 +14,7 @@ import ( // StoreLastKnown stores the the last known version. Future update checks compare // against it, or the currently running version, whichever is newer. func StoreLastKnown(v updates.Version) error { - return os.WriteFile(DataDirPath("lastknownversion"), []byte(v.String()), 0660) + return os.WriteFile(mox.DataDirPath("lastknownversion"), []byte(v.String()), 0660) } // LastKnown returns the last known version that has been mentioned in an update @@ -21,7 +22,7 @@ func StoreLastKnown(v updates.Version) error { func LastKnown() (current, lastknown updates.Version, mtime time.Time, rerr error) { curv, curerr := updates.ParseVersion(moxvar.VersionBare) - p := DataDirPath("lastknownversion") + p := mox.DataDirPath("lastknownversion") fi, _ := os.Stat(p) if fi != nil { mtime = fi.ModTime() diff --git a/webaccount/account.go b/webaccount/account.go index d5cef38b5..cc0d74b63 100644 --- a/webaccount/account.go +++ b/webaccount/account.go @@ -26,6 +26,7 @@ import ( "github.com/mjl-/sherpadoc" "github.com/mjl-/sherpaprom" + "github.com/mjl-/mox/admin" "github.com/mjl-/mox/config" "github.com/mjl-/mox/mlog" "github.com/mjl-/mox/mox-" @@ -110,7 +111,7 @@ func xcheckf(ctx context.Context, err error, format string, args ...any) { return } // If caller tried saving a config that is invalid, or because of a bad request, cause a user error. - if errors.Is(err, mox.ErrConfig) || errors.Is(err, mox.ErrRequest) { + if errors.Is(err, mox.ErrConfig) || errors.Is(err, admin.ErrRequest) { xcheckuserf(ctx, err, format, args...) } @@ -433,7 +434,7 @@ func (Account) Account(ctx context.Context) (account config.Account, storageUsed // for the account. func (Account) AccountSaveFullName(ctx context.Context, fullName string) { reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo) - err := mox.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) { + err := admin.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) { acc.FullName = fullName }) xcheckf(ctx, err, "saving account full name") @@ -445,7 +446,7 @@ func (Account) AccountSaveFullName(ctx context.Context, fullName string) { func (Account) DestinationSave(ctx context.Context, destName string, oldDest, newDest config.Destination) { reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo) - err := mox.AccountSave(ctx, reqInfo.AccountName, func(conf *config.Account) { + err := admin.AccountSave(ctx, reqInfo.AccountName, func(conf *config.Account) { curDest, ok := conf.Destinations[destName] if !ok { xcheckuserf(ctx, errors.New("not found"), "looking up destination") @@ -527,7 +528,7 @@ func (Account) SuppressionRemove(ctx context.Context, address string) { // to be delivered, or all if empty/nil. func (Account) OutgoingWebhookSave(ctx context.Context, url, authorization string, events []string) { reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo) - err := mox.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) { + err := admin.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) { if url == "" { acc.OutgoingWebhook = nil } else { @@ -566,7 +567,7 @@ func (Account) OutgoingWebhookTest(ctx context.Context, urlStr, authorization st // the Authorization header in requests. func (Account) IncomingWebhookSave(ctx context.Context, url, authorization string) { reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo) - err := mox.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) { + err := admin.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) { if url == "" { acc.IncomingWebhook = nil } else { @@ -611,7 +612,7 @@ func (Account) IncomingWebhookTest(ctx context.Context, urlStr, authorization st // MAIL FROM addresses ("fromid") for deliveries from the queue. func (Account) FromIDLoginAddressesSave(ctx context.Context, loginAddresses []string) { reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo) - err := mox.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) { + err := admin.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) { acc.FromIDLoginAddresses = loginAddresses }) xcheckf(ctx, err, "saving account fromid login addresses") @@ -620,7 +621,7 @@ func (Account) FromIDLoginAddressesSave(ctx context.Context, loginAddresses []st // KeepRetiredPeriodsSave saves periods to save retired messages and webhooks. func (Account) KeepRetiredPeriodsSave(ctx context.Context, keepRetiredMessagePeriod, keepRetiredWebhookPeriod time.Duration) { reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo) - err := mox.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) { + err := admin.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) { acc.KeepRetiredMessagePeriod = keepRetiredMessagePeriod acc.KeepRetiredWebhookPeriod = keepRetiredWebhookPeriod }) @@ -631,7 +632,7 @@ func (Account) KeepRetiredPeriodsSave(ctx context.Context, keepRetiredMessagePer // junk/nonjunk when moved to mailboxes matching certain regular expressions. func (Account) AutomaticJunkFlagsSave(ctx context.Context, enabled bool, junkRegexp, neutralRegexp, notJunkRegexp string) { reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo) - err := mox.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) { + err := admin.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) { acc.AutomaticJunkFlags = config.AutomaticJunkFlags{ Enabled: enabled, JunkMailboxRegexp: junkRegexp, @@ -646,7 +647,7 @@ func (Account) AutomaticJunkFlagsSave(ctx context.Context, enabled bool, junkReg // is disabled. Otherwise all fields except Threegrams are stored. func (Account) JunkFilterSave(ctx context.Context, junkFilter *config.JunkFilter) { reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo) - err := mox.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) { + err := admin.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) { if junkFilter == nil { acc.JunkFilter = nil return @@ -664,7 +665,7 @@ func (Account) JunkFilterSave(ctx context.Context, junkFilter *config.JunkFilter // RejectsSave saves the RejectsMailbox and KeepRejects settings. func (Account) RejectsSave(ctx context.Context, mailbox string, keep bool) { reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo) - err := mox.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) { + err := admin.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) { acc.RejectsMailbox = mailbox acc.KeepRejects = keep }) diff --git a/webadmin/admin.go b/webadmin/admin.go index 207ba5d32..9e091438c 100644 --- a/webadmin/admin.go +++ b/webadmin/admin.go @@ -45,6 +45,7 @@ import ( "github.com/mjl-/sherpadoc" "github.com/mjl-/sherpaprom" + "github.com/mjl-/mox/admin" "github.com/mjl-/mox/config" "github.com/mjl-/mox/dkim" "github.com/mjl-/mox/dmarc" @@ -209,7 +210,7 @@ func xcheckf(ctx context.Context, err error, format string, args ...any) { return } // If caller tried saving a config that is invalid, or because of a bad request, cause a user error. - if errors.Is(err, mox.ErrConfig) || errors.Is(err, mox.ErrRequest) { + if errors.Is(err, mox.ErrConfig) || errors.Is(err, admin.ErrRequest) { xcheckuserf(ctx, err, format, args...) } @@ -1898,7 +1899,7 @@ func (Admin) MonitorDNSBLsSave(ctx context.Context, text string) { zones = append(zones, d) } - err := mox.ConfigSave(ctx, func(conf *config.Dynamic) { + err := admin.ConfigSave(ctx, func(conf *config.Dynamic) { conf.MonitorDNSBLs = make([]string, len(zones)) conf.MonitorDNSBLZones = nil for i, z := range zones { @@ -1944,7 +1945,7 @@ func DomainRecords(ctx context.Context, log mlog.Log, domain string) []string { } } - records, err := mox.DomainRecords(dc, d, result.Authentic, certIssuerDomainName, acmeAccountURI) + records, err := admin.DomainRecords(dc, d, result.Authentic, certIssuerDomainName, acmeAccountURI) xcheckf(ctx, err, "dns records") return records } @@ -1954,7 +1955,7 @@ func (Admin) DomainAdd(ctx context.Context, domain, accountName, localpart strin d, err := dns.ParseDomain(domain) xcheckuserf(ctx, err, "parsing domain") - err = mox.DomainAdd(ctx, d, accountName, smtp.Localpart(norm.NFC.String(localpart))) + err = admin.DomainAdd(ctx, d, accountName, smtp.Localpart(norm.NFC.String(localpart))) xcheckf(ctx, err, "adding domain") } @@ -1963,32 +1964,32 @@ func (Admin) DomainRemove(ctx context.Context, domain string) { d, err := dns.ParseDomain(domain) xcheckuserf(ctx, err, "parsing domain") - err = mox.DomainRemove(ctx, d) + err = admin.DomainRemove(ctx, d) xcheckf(ctx, err, "removing domain") } // AccountAdd adds existing a new account, with an initial email address, and // reloads the configuration. func (Admin) AccountAdd(ctx context.Context, accountName, address string) { - err := mox.AccountAdd(ctx, accountName, address) + err := admin.AccountAdd(ctx, accountName, address) xcheckf(ctx, err, "adding account") } // AccountRemove removes an existing account and reloads the configuration. func (Admin) AccountRemove(ctx context.Context, accountName string) { - err := mox.AccountRemove(ctx, accountName) + err := admin.AccountRemove(ctx, accountName) xcheckf(ctx, err, "removing account") } // AddressAdd adds a new address to the account, which must already exist. func (Admin) AddressAdd(ctx context.Context, address, accountName string) { - err := mox.AddressAdd(ctx, address, accountName) + err := admin.AddressAdd(ctx, address, accountName) xcheckf(ctx, err, "adding address") } // AddressRemove removes an existing address. func (Admin) AddressRemove(ctx context.Context, address string) { - err := mox.AddressRemove(ctx, address) + err := admin.AddressRemove(ctx, address) xcheckf(ctx, err, "removing address") } @@ -2012,7 +2013,7 @@ func (Admin) SetPassword(ctx context.Context, accountName, password string) { // AccountSettingsSave set new settings for an account that only an admin can set. func (Admin) AccountSettingsSave(ctx context.Context, accountName string, maxOutgoingMessagesPerDay, maxFirstTimeRecipientsPerDay int, maxMsgSize int64, firstTimeSenderDelay bool) { - err := mox.AccountSave(ctx, accountName, func(acc *config.Account) { + err := admin.AccountSave(ctx, accountName, func(acc *config.Account) { acc.MaxOutgoingMessagesPerDay = maxOutgoingMessagesPerDay acc.MaxFirstTimeRecipientsPerDay = maxFirstTimeRecipientsPerDay acc.QuotaMessageSize = maxMsgSize @@ -2023,11 +2024,11 @@ func (Admin) AccountSettingsSave(ctx context.Context, accountName string, maxOut // ClientConfigsDomain returns configurations for email clients, IMAP and // Submission (SMTP) for the domain. -func (Admin) ClientConfigsDomain(ctx context.Context, domain string) mox.ClientConfigs { +func (Admin) ClientConfigsDomain(ctx context.Context, domain string) admin.ClientConfigs { d, err := dns.ParseDomain(domain) xcheckuserf(ctx, err, "parsing domain") - cc, err := mox.ClientConfigsDomain(d) + cc, err := admin.ClientConfigsDomain(d) xcheckf(ctx, err, "client config for domain") return cc } @@ -2281,7 +2282,7 @@ func (Admin) WebserverConfigSave(ctx context.Context, oldConf, newConf Webserver domainRedirects[x[0]] = x[1] } - err := mox.ConfigSave(ctx, func(conf *config.Dynamic) { + err := admin.ConfigSave(ctx, func(conf *config.Dynamic) { conf.WebDomainRedirects = domainRedirects conf.WebHandlers = newConf.WebHandlers }) @@ -2458,7 +2459,7 @@ func (Admin) Config(ctx context.Context) config.Dynamic { // AccountRoutesSave saves routes for an account. func (Admin) AccountRoutesSave(ctx context.Context, accountName string, routes []config.Route) { - err := mox.AccountSave(ctx, accountName, func(acc *config.Account) { + err := admin.AccountSave(ctx, accountName, func(acc *config.Account) { acc.Routes = routes }) xcheckf(ctx, err, "saving account routes") @@ -2466,7 +2467,7 @@ func (Admin) AccountRoutesSave(ctx context.Context, accountName string, routes [ // DomainRoutesSave saves routes for a domain. func (Admin) DomainRoutesSave(ctx context.Context, domainName string, routes []config.Route) { - err := mox.DomainSave(ctx, domainName, func(domain *config.Domain) error { + err := admin.DomainSave(ctx, domainName, func(domain *config.Domain) error { domain.Routes = routes return nil }) @@ -2475,7 +2476,7 @@ func (Admin) DomainRoutesSave(ctx context.Context, domainName string, routes []c // RoutesSave saves global routes. func (Admin) RoutesSave(ctx context.Context, routes []config.Route) { - err := mox.ConfigSave(ctx, func(config *config.Dynamic) { + err := admin.ConfigSave(ctx, func(config *config.Dynamic) { config.Routes = routes }) xcheckf(ctx, err, "saving global routes") @@ -2483,7 +2484,7 @@ func (Admin) RoutesSave(ctx context.Context, routes []config.Route) { // DomainDescriptionSave saves the description for a domain. func (Admin) DomainDescriptionSave(ctx context.Context, domainName, descr string) { - err := mox.DomainSave(ctx, domainName, func(domain *config.Domain) error { + err := admin.DomainSave(ctx, domainName, func(domain *config.Domain) error { domain.Description = descr return nil }) @@ -2492,7 +2493,7 @@ func (Admin) DomainDescriptionSave(ctx context.Context, domainName, descr string // DomainClientSettingsDomainSave saves the client settings domain for a domain. func (Admin) DomainClientSettingsDomainSave(ctx context.Context, domainName, clientSettingsDomain string) { - err := mox.DomainSave(ctx, domainName, func(domain *config.Domain) error { + err := admin.DomainSave(ctx, domainName, func(domain *config.Domain) error { domain.ClientSettingsDomain = clientSettingsDomain return nil }) @@ -2502,7 +2503,7 @@ func (Admin) DomainClientSettingsDomainSave(ctx context.Context, domainName, cli // DomainLocalpartConfigSave saves the localpart catchall and case-sensitive // settings for a domain. func (Admin) DomainLocalpartConfigSave(ctx context.Context, domainName, localpartCatchallSeparator string, localpartCaseSensitive bool) { - err := mox.DomainSave(ctx, domainName, func(domain *config.Domain) error { + err := admin.DomainSave(ctx, domainName, func(domain *config.Domain) error { domain.LocalpartCatchallSeparator = localpartCatchallSeparator domain.LocalpartCaseSensitive = localpartCaseSensitive return nil @@ -2514,7 +2515,7 @@ func (Admin) DomainLocalpartConfigSave(ctx context.Context, domainName, localpar // configuration for a domain. If localpart is empty, processing reports is // disabled. func (Admin) DomainDMARCAddressSave(ctx context.Context, domainName, localpart, domain, account, mailbox string) { - err := mox.DomainSave(ctx, domainName, func(d *config.Domain) error { + err := admin.DomainSave(ctx, domainName, func(d *config.Domain) error { if localpart == "" { d.DMARC = nil } else { @@ -2534,7 +2535,7 @@ func (Admin) DomainDMARCAddressSave(ctx context.Context, domainName, localpart, // configuration for a domain. If localpart is empty, processing reports is // disabled. func (Admin) DomainTLSRPTAddressSave(ctx context.Context, domainName, localpart, domain, account, mailbox string) { - err := mox.DomainSave(ctx, domainName, func(d *config.Domain) error { + err := admin.DomainSave(ctx, domainName, func(d *config.Domain) error { if localpart == "" { d.TLSRPT = nil } else { @@ -2553,7 +2554,7 @@ func (Admin) DomainTLSRPTAddressSave(ctx context.Context, domainName, localpart, // DomainMTASTSSave saves the MTASTS policy for a domain. If policyID is empty, // no MTASTS policy is served. func (Admin) DomainMTASTSSave(ctx context.Context, domainName, policyID string, mode mtasts.Mode, maxAge time.Duration, mx []string) { - err := mox.DomainSave(ctx, domainName, func(d *config.Domain) error { + err := admin.DomainSave(ctx, domainName, func(d *config.Domain) error { if policyID == "" { d.MTASTS = nil } else { @@ -2576,7 +2577,7 @@ func (Admin) DomainDKIMAdd(ctx context.Context, domainName, selector, algorithm, xcheckuserf(ctx, err, "parsing domain") s, err := dns.ParseDomain(selector) xcheckuserf(ctx, err, "parsing selector") - err = mox.DKIMAdd(ctx, d, s, algorithm, hash, headerRelaxed, bodyRelaxed, seal, headers, lifetime) + err = admin.DKIMAdd(ctx, d, s, algorithm, hash, headerRelaxed, bodyRelaxed, seal, headers, lifetime) xcheckf(ctx, err, "adding dkim key") } @@ -2586,7 +2587,7 @@ func (Admin) DomainDKIMRemove(ctx context.Context, domainName, selector string) xcheckuserf(ctx, err, "parsing domain") s, err := dns.ParseDomain(selector) xcheckuserf(ctx, err, "parsing selector") - err = mox.DKIMRemove(ctx, d, s) + err = admin.DKIMRemove(ctx, d, s) xcheckf(ctx, err, "removing dkim key") } @@ -2600,7 +2601,7 @@ func (Admin) DomainDKIMSave(ctx context.Context, domainName string, selectors ma } } - err := mox.DomainSave(ctx, domainName, func(d *config.Domain) error { + err := admin.DomainSave(ctx, domainName, func(d *config.Domain) error { if len(selectors) != len(d.DKIM.Selectors) { xcheckuserf(ctx, fmt.Errorf("cannot add/remove dkim selectors with this function"), "checking selectors") } @@ -2649,7 +2650,7 @@ func xparseAddress(ctx context.Context, lp, domain string) smtp.Address { func (Admin) AliasAdd(ctx context.Context, aliaslp string, domainName string, alias config.Alias) { addr := xparseAddress(ctx, aliaslp, domainName) - err := mox.AliasAdd(ctx, addr, alias) + err := admin.AliasAdd(ctx, addr, alias) xcheckf(ctx, err, "adding alias") } @@ -2660,24 +2661,24 @@ func (Admin) AliasUpdate(ctx context.Context, aliaslp string, domainName string, ListMembers: listMembers, AllowMsgFrom: allowMsgFrom, } - err := mox.AliasUpdate(ctx, addr, alias) + err := admin.AliasUpdate(ctx, addr, alias) xcheckf(ctx, err, "saving alias") } func (Admin) AliasRemove(ctx context.Context, aliaslp string, domainName string) { addr := xparseAddress(ctx, aliaslp, domainName) - err := mox.AliasRemove(ctx, addr) + err := admin.AliasRemove(ctx, addr) xcheckf(ctx, err, "removing alias") } func (Admin) AliasAddressesAdd(ctx context.Context, aliaslp string, domainName string, addresses []string) { addr := xparseAddress(ctx, aliaslp, domainName) - err := mox.AliasAddressesAdd(ctx, addr, addresses) + err := admin.AliasAddressesAdd(ctx, addr, addresses) xcheckf(ctx, err, "adding address to alias") } func (Admin) AliasAddressesRemove(ctx context.Context, aliaslp string, domainName string, addresses []string) { addr := xparseAddress(ctx, aliaslp, domainName) - err := mox.AliasAddressesRemove(ctx, addr, addresses) + err := admin.AliasAddressesRemove(ctx, addr, addresses) xcheckf(ctx, err, "removing address from alias") } diff --git a/webmail/api.go b/webmail/api.go index fc9b7e30b..1800d4c10 100644 --- a/webmail/api.go +++ b/webmail/api.go @@ -33,6 +33,7 @@ import ( "github.com/mjl-/sherpadoc" "github.com/mjl-/sherpaprom" + "github.com/mjl-/mox/admin" "github.com/mjl-/mox/config" "github.com/mjl-/mox/dkim" "github.com/mjl-/mox/dns" @@ -1986,7 +1987,7 @@ func parseListID(s string) (listID string, dom dns.Domain) { func (Webmail) RulesetAdd(ctx context.Context, rcptTo string, ruleset config.Ruleset) { reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo) - err := mox.AccountSave(ctx, reqInfo.Account.Name, func(acc *config.Account) { + err := admin.AccountSave(ctx, reqInfo.Account.Name, func(acc *config.Account) { dest, ok := acc.Destinations[rcptTo] if !ok { // todo: we could find the catchall address and add the rule, or add the address explicitly. @@ -2007,7 +2008,7 @@ func (Webmail) RulesetAdd(ctx context.Context, rcptTo string, ruleset config.Rul func (Webmail) RulesetRemove(ctx context.Context, rcptTo string, ruleset config.Ruleset) { reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo) - err := mox.AccountSave(ctx, reqInfo.Account.Name, func(acc *config.Account) { + err := admin.AccountSave(ctx, reqInfo.Account.Name, func(acc *config.Account) { dest, ok := acc.Destinations[rcptTo] if !ok { xcheckuserf(ctx, errors.New("destination address not found in account"), "looking up address") From 8804d6b60ec058e9584c03384a04a93e102d63ba Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Thu, 5 Dec 2024 22:41:49 +0100 Subject: [PATCH 14/20] implement tls client certificate authentication the imap & smtp servers now allow logging in with tls client authentication and the "external" sasl authentication mechanism. email clients like thunderbird, fairemail, k9, macos mail implement it. this seems to be the most secure among the authentication mechanism commonly implemented by clients. a useful property is that an account can have a separate tls public key for each device/email client. with tls client cert auth, authentication is also bound to the tls connection. a mitm cannot pass the credentials on to another tls connection, similar to scram-*-plus. though part of scram-*-plus is that clients verify that the server knows the client credentials. for tls client auth with imap, we send a "preauth" untagged message by default. that puts the connection in authenticated state. given the imap connection state machine, further authentication commands are not allowed. some clients don't recognize the preauth message, and try to authenticate anyway, which fails. a tls public key has a config option to disable preauth, keeping new connections in unauthenticated state, to work with such email clients. for smtp (submission), we don't require an explicit auth command. both for imap and smtp, we allow a client to authenticate with another mechanism than "external". in that case, credentials are verified, and have to be for the same account as the tls client auth, but the adress can be another one than the login address configured with the tls public key. only the public key is used to identify the account that is authenticating. we ignore the rest of the certificate. expiration dates, names, constraints, etc are not verified. no certificate authorities are involved. users can upload their own (minimal) certificate. the account web interface shows openssl commands you can run to generate a private key, minimal cert, and a p12 file (the format that email clients seem to like...) containing both private key and certificate. the imapclient & smtpclient packages can now also use tls client auth. and so does "mox sendmail", either with a pem file with private key and certificate, or with just an ed25519 private key. there are new subcommands "mox config tlspubkey ..." for adding/removing/listing tls public keys from the cli, by the admin. --- admin/admin.go | 47 +++- backup.go | 3 +- ctl.go | 89 ++++++ ctl_test.go | 57 ++++ doc.go | 60 ++++ gentestdata.go | 6 + http/autoconf.go | 3 + imapclient/client.go | 8 +- imapserver/authenticate_test.go | 154 +++++++++++ imapserver/server.go | 429 +++++++++++++++++++++++------ imapserver/server_test.go | 62 ++++- main.go | 199 ++++++++++++++ metrics/auth.go | 2 +- mox-/lifecycle.go | 2 +- mox-/lookup.go | 2 +- mox-/sleep.go | 4 +- mox-/tlsalert.go | 23 ++ mox-/tlssessionticket.go | 51 ++++ sasl/sasl.go | 28 ++ sendmail.go | 132 ++++++++- serve.go | 4 + smtpclient/client.go | 19 +- smtpserver/server.go | 474 ++++++++++++++++++++++---------- smtpserver/server_test.go | 181 ++++++++++-- store/tlspubkey.go | 168 +++++++++++ testdata/imap/domains.conf | 4 + verifydata.go | 3 +- webaccount/account.go | 78 ++++++ webaccount/account.js | 137 ++++++++- webaccount/account.ts | 201 +++++++++++++- webaccount/account_test.go | 64 +++++ webaccount/api.json | 141 ++++++++++ webaccount/api.ts | 49 +++- webadmin/admin.go | 4 + webadmin/admin.js | 19 +- webadmin/admin.ts | 30 +- webadmin/api.json | 84 ++++++ webadmin/api.ts | 25 +- 38 files changed, 2737 insertions(+), 309 deletions(-) create mode 100644 mox-/tlsalert.go create mode 100644 mox-/tlssessionticket.go create mode 100644 store/tlspubkey.go diff --git a/admin/admin.go b/admin/admin.go index 17d5172b2..96746c463 100644 --- a/admin/admin.go +++ b/admin/admin.go @@ -26,6 +26,7 @@ import ( "github.com/mjl-/mox/mox-" "github.com/mjl-/mox/mtasts" "github.com/mjl-/mox/smtp" + "github.com/mjl-/mox/store" ) var pkglog = mlog.New("admin", nil) @@ -514,6 +515,18 @@ func DomainRemove(ctx context.Context, domain dns.Domain) (rerr error) { return fmt.Errorf("%w: domain does not exist", ErrRequest) } + // Check that the domain isn't referenced in a TLS public key. + tlspubkeys, err := store.TLSPublicKeyList(ctx, "") + if err != nil { + return fmt.Errorf("%w: listing tls public keys: %s", ErrRequest, err) + } + atdom := "@" + domain.Name() + for _, tpk := range tlspubkeys { + if strings.HasSuffix(tpk.LoginAddress, atdom) { + return fmt.Errorf("%w: domain is still referenced in tls public key by login address %q of account %q, change or remove it first", ErrRequest, tpk.LoginAddress, tpk.Account) + } + } + // Compose new config without modifying existing data structures. If we fail, we // leave no trace. nc := c @@ -720,6 +733,11 @@ func AccountRemove(ctx context.Context, account string) (rerr error) { return fmt.Errorf("account removed, its data directory moved to %q, but removing failed: %v", odir, err) } + if err := store.TLSPublicKeyRemoveForAccount(context.Background(), account); err != nil { + log.Errorx("removing tls public keys for removed account", err) + return fmt.Errorf("account removed, but removing tls public keys failed: %v", err) + } + log.Info("account removed", slog.String("account", account)) return nil } @@ -851,7 +869,7 @@ func AddressRemove(ctx context.Context, address string) (rerr error) { } // Also remove matching address from FromIDLoginAddresses, composing a new slice. - var fromIDLoginAddresses []string + // Refuse if address is referenced in a TLS public key. var dom dns.Domain var pa smtp.Address // For non-catchall addresses (most). var err error @@ -867,6 +885,12 @@ func AddressRemove(ctx context.Context, address string) (rerr error) { } dom = pa.Domain } + dc, ok := mox.Conf.Dynamic.Domains[dom.Name()] + if !ok { + return fmt.Errorf("%w: unknown domain in address %q", ErrRequest, address) + } + + var fromIDLoginAddresses []string for i, fa := range a.ParsedFromIDLoginAddresses { if fa.Domain != dom { // Keep for different domain. @@ -876,10 +900,6 @@ func AddressRemove(ctx context.Context, address string) (rerr error) { if strings.HasPrefix(address, "@") { continue } - dc, ok := mox.Conf.Dynamic.Domains[dom.Name()] - if !ok { - return fmt.Errorf("%w: unknown domain in fromid login address %q", ErrRequest, fa.Pack(true)) - } flp := mox.CanonicalLocalpart(fa.Localpart, dc) alp := mox.CanonicalLocalpart(pa.Localpart, dc) if alp != flp { @@ -889,6 +909,23 @@ func AddressRemove(ctx context.Context, address string) (rerr error) { } na.FromIDLoginAddresses = fromIDLoginAddresses + // Refuse if there is still a TLS public key that references this address. + tlspubkeys, err := store.TLSPublicKeyList(ctx, ad.Account) + if err != nil { + return fmt.Errorf("%w: listing tls public keys for account: %v", ErrRequest, err) + } + for _, tpk := range tlspubkeys { + a, err := smtp.ParseAddress(tpk.LoginAddress) + if err != nil { + return fmt.Errorf("%w: parsing address from tls public key: %v", ErrRequest, err) + } + lp := mox.CanonicalLocalpart(a.Localpart, dc) + ca := smtp.NewAddress(lp, a.Domain) + if xad, ok := mox.Conf.AccountDestinationsLocked[ca.String()]; ok && xad.Localpart == ad.Localpart { + return fmt.Errorf("%w: tls public key %q references this address as login address %q, remove the tls public key before removing the address", ErrRequest, tpk.Fingerprint, tpk.LoginAddress) + } + } + // And remove as member from aliases configured in domains. domains := maps.Clone(mox.Conf.Dynamic.Domains) for _, aa := range na.Aliases { diff --git a/backup.go b/backup.go index eaa18d18b..b8e172a9d 100644 --- a/backup.go +++ b/backup.go @@ -288,6 +288,7 @@ func backupctl(ctx context.Context, ctl *ctl) { if err := os.WriteFile(filepath.Join(dstDataDir, "moxversion"), []byte(moxvar.Version), 0660); err != nil { xerrx("writing moxversion", err) } + backupDB(store.AuthDB, "auth.db") backupDB(dmarcdb.ReportsDB, "dmarcrpt.db") backupDB(dmarcdb.EvalDB, "dmarceval.db") backupDB(mtastsdb.DB, "mtasts.db") @@ -548,7 +549,7 @@ func backupctl(ctx context.Context, ctl *ctl) { } switch p { - case "dmarcrpt.db", "dmarceval.db", "mtasts.db", "tlsrpt.db", "tlsrptresult.db", "receivedid.key", "ctl": + case "auth.db", "dmarcrpt.db", "dmarceval.db", "mtasts.db", "tlsrpt.db", "tlsrptresult.db", "receivedid.key", "ctl": // Already handled. return nil case "lastknownversion": // Optional file, not yet handled. diff --git a/ctl.go b/ctl.go index ed0018d59..0efeb2a0c 100644 --- a/ctl.go +++ b/ctl.go @@ -2,6 +2,7 @@ package main import ( "bufio" + "bytes" "context" "encoding/json" "errors" @@ -1015,6 +1016,94 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) { ctl.xcheck(err, "removing account") ctl.xwriteok() + case "tlspubkeylist": + /* protocol: + > "tlspubkeylist" + > account (or empty) + < "ok" or error + < stream + */ + accountOpt := ctl.xread() + tlspubkeys, err := store.TLSPublicKeyList(ctx, accountOpt) + ctl.xcheck(err, "list tls public keys") + ctl.xwriteok() + xw := ctl.writer() + fmt.Fprintf(xw, "# fingerprint, type, name, account, login address, no imap preauth (%d)\n", len(tlspubkeys)) + for _, k := range tlspubkeys { + fmt.Fprintf(xw, "%s\t%s\t%q\t%s\t%s\t%v\n", k.Fingerprint, k.Type, k.Name, k.Account, k.LoginAddress, k.NoIMAPPreauth) + } + xw.xclose() + + case "tlspubkeyget": + /* protocol: + > "tlspubkeyget" + > fingerprint + < "ok" or error + < type + < name + < account + < address + < noimappreauth (true/false) + < stream (certder) + */ + fp := ctl.xread() + tlspubkey, err := store.TLSPublicKeyGet(ctx, fp) + ctl.xcheck(err, "looking tls public key") + ctl.xwriteok() + ctl.xwrite(tlspubkey.Type) + ctl.xwrite(tlspubkey.Name) + ctl.xwrite(tlspubkey.Account) + ctl.xwrite(tlspubkey.LoginAddress) + ctl.xwrite(fmt.Sprintf("%v", tlspubkey.NoIMAPPreauth)) + ctl.xstreamfrom(bytes.NewReader(tlspubkey.CertDER)) + + case "tlspubkeyadd": + /* protocol: + > "tlspubkeyadd" + > loginaddress + > name (or empty) + > noimappreauth (true/false) + > stream (certder) + < "ok" or error + */ + loginAddress := ctl.xread() + name := ctl.xread() + noimappreauth := ctl.xread() + if noimappreauth != "true" && noimappreauth != "false" { + ctl.xcheck(fmt.Errorf("bad value %q", noimappreauth), "parsing noimappreauth") + } + var b bytes.Buffer + ctl.xstreamto(&b) + tlspubkey, err := store.ParseTLSPublicKeyCert(b.Bytes()) + ctl.xcheck(err, "parsing certificate") + if name != "" { + tlspubkey.Name = name + } + acc, _, err := store.OpenEmail(ctl.log, loginAddress) + ctl.xcheck(err, "open account for address") + defer func() { + err := acc.Close() + ctl.log.Check(err, "close account") + }() + tlspubkey.Account = acc.Name + tlspubkey.LoginAddress = loginAddress + tlspubkey.NoIMAPPreauth = noimappreauth == "true" + + err = store.TLSPublicKeyAdd(ctx, &tlspubkey) + ctl.xcheck(err, "adding tls public key") + ctl.xwriteok() + + case "tlspubkeyrm": + /* protocol: + > "tlspubkeyadd" + > fingerprint + < "ok" or error + */ + fp := ctl.xread() + err := store.TLSPublicKeyRemove(ctx, fp) + ctl.xcheck(err, "removing tls public key") + ctl.xwriteok() + case "addressadd": /* protocol: > "addressadd" diff --git a/ctl_test.go b/ctl_test.go index 53dee1b6d..1a7c8464c 100644 --- a/ctl_test.go +++ b/ctl_test.go @@ -4,8 +4,12 @@ package main import ( "context" + "crypto/ed25519" + cryptorand "crypto/rand" + "crypto/x509" "flag" "fmt" + "math/big" "net" "os" "path/filepath" @@ -50,6 +54,10 @@ func TestCtl(t *testing.T) { tcheck(t, err, "queue init") defer queue.Shutdown() + err = store.Init(ctxbg) + tcheck(t, err, "store init") + defer store.Close() + testctl := func(fn func(clientctl *ctl)) { t.Helper() @@ -334,6 +342,43 @@ func TestCtl(t *testing.T) { ctlcmdConfigAliasRemove(ctl, "support@mox.example") }) + // accounttlspubkeyadd + certDER := fakeCert(t) + testctl(func(ctl *ctl) { + ctlcmdConfigTlspubkeyAdd(ctl, "mjl@mox.example", "testkey", false, certDER) + }) + + // "accounttlspubkeylist" + testctl(func(ctl *ctl) { + ctlcmdConfigTlspubkeyList(ctl, "") + }) + testctl(func(ctl *ctl) { + ctlcmdConfigTlspubkeyList(ctl, "mjl") + }) + + tpkl, err := store.TLSPublicKeyList(ctxbg, "") + tcheck(t, err, "list tls public keys") + if len(tpkl) != 1 { + t.Fatalf("got %d tls public keys, expected 1", len(tpkl)) + } + fingerprint := tpkl[0].Fingerprint + + // "accounttlspubkeyget" + testctl(func(ctl *ctl) { + ctlcmdConfigTlspubkeyGet(ctl, fingerprint) + }) + + // "accounttlspubkeyrm" + testctl(func(ctl *ctl) { + ctlcmdConfigTlspubkeyRemove(ctl, fingerprint) + }) + + tpkl, err = store.TLSPublicKeyList(ctxbg, "") + tcheck(t, err, "list tls public keys") + if len(tpkl) != 0 { + t.Fatalf("got %d tls public keys, expected 0", len(tpkl)) + } + // "loglevels" testctl(func(ctl *ctl) { ctlcmdLoglevels(ctl) @@ -453,3 +498,15 @@ func TestCtl(t *testing.T) { } cmdVerifydata(&xcmd) } + +func fakeCert(t *testing.T) []byte { + t.Helper() + seed := make([]byte, ed25519.SeedSize) + privKey := ed25519.NewKeyFromSeed(seed) // Fake key, don't use this for real! + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), // Required field... + } + localCertBuf, err := x509.CreateCertificate(cryptorand.Reader, template, template, privKey.Public(), privKey) + tcheck(t, err, "making certificate") + return localCertBuf +} diff --git a/doc.go b/doc.go index 9498ee0f5..cc262871f 100644 --- a/doc.go +++ b/doc.go @@ -69,6 +69,11 @@ any parameters. Followed by the help and usage information for each command. mox config address rm address mox config domain add domain account [localpart] mox config domain rm domain + mox config tlspubkey list [account] + mox config tlspubkey get fingerprint + mox config tlspubkey add address [name] < cert.pem + mox config tlspubkey rm fingerprint + mox config tlspubkey gen stem mox config alias list domain mox config alias print alias mox config alias add alias@domain rcpt1@domain ... @@ -994,6 +999,61 @@ rejected. usage: mox config domain rm domain +# mox config tlspubkey list + +List TLS public keys for TLS client certificate authentication. + +If account is absent, the TLS public keys for all accounts are listed. + + usage: mox config tlspubkey list [account] + +# mox config tlspubkey get + +Get a TLS public key for a fingerprint. + +Prints the type, name, account and address for the key, and the certificate in +PEM format. + + usage: mox config tlspubkey get fingerprint + +# mox config tlspubkey add + +Add a TLS public key to the account of the given address. + +The public key is read from the certificate. + +The optional name is a human-readable descriptive name of the key. If absent, +the CommonName from the certificate is used. + + usage: mox config tlspubkey add address [name] < cert.pem + -no-imap-preauth + Don't automatically switch new IMAP connections authenticated with this key to "authenticated" state after the TLS handshake. For working around clients that ignore the untagged IMAP PREAUTH response and try to authenticate while already authenticated. + +# mox config tlspubkey rm + +Remove TLS public key for fingerprint. + + usage: mox config tlspubkey rm fingerprint + +# mox config tlspubkey gen + +Generate an ed25519 private key and minimal certificate for use a TLS public key and write to files starting with stem. + +The private key is written to $stem.$timestamp.ed25519privatekey.pkcs8.pem. +The certificate is written to $stem.$timestamp.certificate.pem. +The private key and certificate are also written to +$stem.$timestamp.ed25519privatekey-certificate.pem. + +The certificate can be added to an account with "mox config account tlspubkey add". + +The combined file can be used with "mox sendmail". + +The private key is also written to standard error in raw-url-base64-encoded +form, also for use with "mox sendmail". The fingerprint is written to standard +error too, for reference. + + usage: mox config tlspubkey gen stem + # mox config alias list List aliases for domain. diff --git a/gentestdata.go b/gentestdata.go index b71bfa8c4..544c03841 100644 --- a/gentestdata.go +++ b/gentestdata.go @@ -187,6 +187,12 @@ Accounts: err = os.WriteFile(filepath.Join(destDataDir, "moxversion"), []byte(moxvar.Version), 0660) xcheckf(err, "writing moxversion") + // Populate auth.db + err = store.Init(ctxbg) + xcheckf(err, "store init") + err = store.TLSPublicKeyAdd(ctxbg, &store.TLSPublicKey{Fingerprint: "...", Type: "ecdsa-p256", CertDER: []byte("..."), Account: "test0", LoginAddress: "test0@mox.example"}) + xcheckf(err, "adding tlspubkey") + // Populate dmarc.db. err = dmarcdb.Init() xcheckf(err, "dmarcdb init") diff --git a/http/autoconf.go b/http/autoconf.go index 383779d3c..25359b65f 100644 --- a/http/autoconf.go +++ b/http/autoconf.go @@ -105,6 +105,7 @@ func autoconfHandle(w http.ResponseWriter, r *http.Request) { resp.EmailProvider.DisplayShortName = addr.Domain.ASCII // todo: specify SCRAM-SHA-256 once thunderbird and autoconfig supports it. or perhaps that will fall under "password-encrypted" by then. + // todo: let user configure they prefer or require tls client auth and specify "TLS-client-cert" resp.EmailProvider.IncomingServer.Type = "imap" resp.EmailProvider.IncomingServer.Hostname = config.IMAP.Host.ASCII @@ -208,6 +209,8 @@ func autodiscoverHandle(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/xml; charset=utf-8") + // todo: let user configure they prefer or require tls client auth and add "AuthPackage" with value "certificate" to Protocol? see https://learn.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxdscli/21fd2dd5-c4ee-485b-94fb-e7db5da93726 + resp := autodiscoverResponse{} resp.XMLName.Local = "Autodiscover" resp.XMLName.Space = "http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006" diff --git a/imapclient/client.go b/imapclient/client.go index 6f45fda2f..019a27515 100644 --- a/imapclient/client.go +++ b/imapclient/client.go @@ -32,6 +32,7 @@ type Conn struct { record bool // If true, bytes read are added to recordBuf. recorded() resets. recordBuf []byte + Preauth bool LastTag string CapAvailable map[Capability]struct{} // Capabilities available at server, from CAPABILITY command or response code. CapEnabled map[Capability]struct{} // Capabilities enabled through ENABLE command. @@ -53,7 +54,9 @@ func (e Error) Unwrap() error { // If xpanic is true, functions that would return an error instead panic. For parse // errors, the resulting stack traces show typically show what was being parsed. // -// The initial untagged greeting response is read and must be "OK". +// The initial untagged greeting response is read and must be "OK" or +// "PREAUTH". If preauth, the connection is already in authenticated state, +// typically through TLS client certificate. This is indicated in Conn.Preauth. func New(conn net.Conn, xpanic bool) (client *Conn, rerr error) { c := Conn{ conn: conn, @@ -77,7 +80,8 @@ func New(conn net.Conn, xpanic bool) (client *Conn, rerr error) { } return &c, nil case UntaggedPreauth: - c.xerrorf("greeting: unexpected preauth") + c.Preauth = true + return &c, nil case UntaggedBye: c.xerrorf("greeting: server sent bye") default: diff --git a/imapserver/authenticate_test.go b/imapserver/authenticate_test.go index f2e0c54e1..6bba7f251 100644 --- a/imapserver/authenticate_test.go +++ b/imapserver/authenticate_test.go @@ -1,20 +1,28 @@ package imapserver import ( + "context" "crypto/hmac" "crypto/md5" "crypto/sha1" "crypto/sha256" + "crypto/tls" "encoding/base64" "errors" "fmt" "hash" + "net" + "os" + "path/filepath" "strings" "testing" + "time" "golang.org/x/text/secure/precis" + "github.com/mjl-/mox/mox-" "github.com/mjl-/mox/scram" + "github.com/mjl-/mox/store" ) func TestAuthenticateLogin(t *testing.T) { @@ -210,3 +218,149 @@ func TestAuthenticateCRAMMD5(t *testing.T) { auth("ok", "mo\u0301x@mox.example", password1) tc.close() } + +func TestAuthenticateTLSClientCert(t *testing.T) { + tc := startArgs(t, true, true, true, true, "mjl") + tc.transactf("no", "authenticate external ") // No TLS auth. + tc.close() + + // Create a certificate, register its public key with account, and make a tls + // client config that sends the certificate. + clientCert0 := fakeCert(t, true) + clientConfig := tls.Config{ + InsecureSkipVerify: true, + Certificates: []tls.Certificate{clientCert0}, + } + + tlspubkey, err := store.ParseTLSPublicKeyCert(clientCert0.Certificate[0]) + tcheck(t, err, "parse certificate") + tlspubkey.Account = "mjl" + tlspubkey.LoginAddress = "mjl@mox.example" + tlspubkey.NoIMAPPreauth = true + + addClientCert := func() error { + return store.TLSPublicKeyAdd(ctxbg, &tlspubkey) + } + + // No preauth, explicit authenticate with TLS. + tc = startArgsMore(t, true, true, nil, &clientConfig, false, true, true, "mjl", addClientCert) + if tc.client.Preauth { + t.Fatalf("preauthentication while not configured for tls public key") + } + tc.transactf("ok", "authenticate external ") + tc.close() + + // External with explicit username. + tc = startArgsMore(t, true, true, nil, &clientConfig, false, true, true, "mjl", addClientCert) + if tc.client.Preauth { + t.Fatalf("preauthentication while not configured for tls public key") + } + tc.transactf("ok", "authenticate external %s", base64.StdEncoding.EncodeToString([]byte("mjl@mox.example"))) + tc.close() + + // No preauth, also allow other mechanisms. + tc = startArgsMore(t, true, true, nil, &clientConfig, false, true, true, "mjl", addClientCert) + tc.transactf("ok", "authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("\u0000mjl@mox.example\u0000"+password0))) + tc.close() + + // No preauth, also allow other username for same account. + tc = startArgsMore(t, true, true, nil, &clientConfig, false, true, true, "mjl", addClientCert) + tc.transactf("ok", "authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("\u0000móx@mox.example\u0000"+password0))) + tc.close() + + // No preauth, other mechanism must be for same account. + acc, err := store.OpenAccount(pkglog, "other") + tcheck(t, err, "open account") + err = acc.SetPassword(pkglog, "test1234") + tcheck(t, err, "set password") + tc = startArgsMore(t, true, true, nil, &clientConfig, false, true, true, "mjl", addClientCert) + tc.transactf("no", "authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("\u0000other@mox.example\u0000test1234"))) + tc.close() + + // Starttls and external auth. + tc = startArgsMore(t, true, false, nil, &clientConfig, false, true, true, "mjl", addClientCert) + tc.client.Starttls(&clientConfig) + tc.transactf("ok", "authenticate external =") + tc.close() + + tlspubkey.NoIMAPPreauth = false + err = store.TLSPublicKeyUpdate(ctxbg, &tlspubkey) + tcheck(t, err, "update tls public key") + + // With preauth, no authenticate command needed/allowed. + // Already set up tls session ticket cache, for next test. + serverConfig := tls.Config{ + Certificates: []tls.Certificate{fakeCert(t, false)}, + } + ctx, cancel := context.WithCancel(ctxbg) + defer cancel() + mox.StartTLSSessionTicketKeyRefresher(ctx, pkglog, &serverConfig) + clientConfig.ClientSessionCache = tls.NewLRUClientSessionCache(10) + tc = startArgsMore(t, true, true, &serverConfig, &clientConfig, false, true, true, "mjl", addClientCert) + if !tc.client.Preauth { + t.Fatalf("not preauthentication while configured for tls public key") + } + cs := tc.conn.(*tls.Conn).ConnectionState() + if cs.DidResume { + t.Fatalf("tls connection was resumed") + } + tc.transactf("no", "authenticate external ") // Not allowed, already in authenticated state. + tc.close() + + // Authentication works with TLS resumption. + tc = startArgsMore(t, true, true, &serverConfig, &clientConfig, false, true, true, "mjl", addClientCert) + if !tc.client.Preauth { + t.Fatalf("not preauthentication while configured for tls public key") + } + cs = tc.conn.(*tls.Conn).ConnectionState() + if !cs.DidResume { + t.Fatalf("tls connection was not resumed") + } + // Check that operations that require an account work. + tc.client.Enable("imap4rev2") + received, err := time.Parse(time.RFC3339, "2022-11-16T10:01:00+01:00") + tc.check(err, "parse time") + tc.client.Append("inbox", nil, &received, []byte(exampleMsg)) + tc.client.Select("inbox") + tc.close() + + // Authentication with unknown key should fail. + // todo: less duplication, change startArgs so this can be merged into it. + err = store.Close() + tcheck(t, err, "store close") + os.RemoveAll("../testdata/imap/data") + err = store.Init(ctxbg) + tcheck(t, err, "store init") + mox.Context = ctxbg + mox.ConfigStaticPath = filepath.FromSlash("../testdata/imap/mox.conf") + mox.MustLoadConfig(true, false) + switchStop := store.Switchboard() + defer switchStop() + + serverConn, clientConn := net.Pipe() + defer clientConn.Close() + + done := make(chan struct{}) + defer func() { <-done }() + connCounter++ + cid := connCounter + go func() { + defer serverConn.Close() + serve("test", cid, &serverConfig, serverConn, true, false) + close(done) + }() + + clientConfig.ClientSessionCache = nil + clientConn = tls.Client(clientConn, &clientConfig) + // note: It's not enough to do a handshake and check if that was successful. If the + // client cert is not acceptable, we only learn after the handshake, when the first + // data messages are exchanged. + buf := make([]byte, 100) + _, err = clientConn.Read(buf) + if err == nil { + t.Fatalf("tls handshake with unknown client certificate succeeded") + } + if alert, ok := mox.AsTLSAlert(err); !ok || alert != 42 { + t.Fatalf("got err %#v, expected tls 'bad certificate' alert", err) + } +} diff --git a/imapserver/server.go b/imapserver/server.go index 48747e3c2..59ad488e2 100644 --- a/imapserver/server.go +++ b/imapserver/server.go @@ -39,6 +39,7 @@ import ( "crypto/sha1" "crypto/sha256" "crypto/tls" + "crypto/x509" "encoding/base64" "errors" "fmt" @@ -147,6 +148,7 @@ var authFailDelay = time.Second // After authentication failure. // SPECIAL-USE: ../rfc/6154 // LIST-STATUS: ../rfc/5819 // ID: ../rfc/2971 +// AUTH=EXTERNAL: ../rfc/4422:1575 // AUTH=SCRAM-SHA-256-PLUS and AUTH=SCRAM-SHA-256: ../rfc/7677 ../rfc/5802 // AUTH=SCRAM-SHA-1-PLUS and AUTH=SCRAM-SHA-1: ../rfc/5802 // AUTH=CRAM-MD5: ../rfc/2195 @@ -175,7 +177,7 @@ type conn struct { tw *moxio.TraceWriter slow bool // If set, reads are done with a 1 second sleep, and writes are done 1 byte at a time, to keep spammers busy. lastlog time.Time // For printing time since previous log line. - tlsConfig *tls.Config // TLS config to use for handshake. + baseTLSConfig *tls.Config // Base TLS config to use for handshake. remoteIP net.IP noRequireSTARTTLS bool cmd string // Currently executing, for deciding to applyChanges and logging. @@ -193,8 +195,12 @@ type conn struct { // ../rfc/5182:13 ../rfc/9051:4040 searchResult []store.UID - // Only when authenticated. + // Only set when connection has been authenticated. These can be set even when + // c.state is stateNotAuthenticated, for TLS client certificate authentication. In + // that case, credentials aren't used until the authentication command with the + // SASL "EXTERNAL" mechanism. authFailed int // Number of failed auth attempts. For slowing down remote with many failures. + noPreauth bool // If set, don't switch connection to "authenticated" after TLS handshake with client certificate authentication. username string // Full username as used during login. account *store.Account comm *store.Comm // For sending/receiving changes on mailboxes in account, e.g. from messages incoming on smtp, or another imap client. @@ -355,8 +361,14 @@ func listen1(protocol, listenerName, ip string, port int, tlsConfig *tls.Config, if err != nil { log.Fatalx("imap: listen for imap", err, slog.String("protocol", protocol), slog.String("listener", listenerName)) } - if xtls { - ln = tls.NewListener(ln, tlsConfig) + + // Each listener gets its own copy of the config, so session keys between different + // ports on same listener aren't shared. We rotate session keys explicitly in this + // base TLS config because each connection clones the TLS config before using. The + // base TLS config would never get automatically managed/rotated session keys. + if tlsConfig != nil { + tlsConfig = tlsConfig.Clone() + mox.StartTLSSessionTicketKeyRefresher(mox.Shutdown, log, tlsConfig) } serve := func() { @@ -637,7 +649,7 @@ func serve(listenerName string, cid int64, tlsConfig *tls.Config, nc net.Conn, x conn: nc, tls: xtls, lastlog: time.Now(), - tlsConfig: tlsConfig, + baseTLSConfig: tlsConfig, remoteIP: remoteIP, noRequireSTARTTLS: noRequireSTARTTLS, enabled: map[capability]bool{}, @@ -660,19 +672,15 @@ func serve(listenerName string, cid int64, tlsConfig *tls.Config, nc net.Conn, x return l }) c.tr = moxio.NewTraceReader(c.log, "C: ", c.conn) - c.tw = moxio.NewTraceWriter(c.log, "S: ", c) // todo: tracing should be done on whatever comes out of c.br. the remote connection write a command plus data, and bufio can read it in one read, causing a command parser that sets the tracing level to data to have no effect. we are now typically logging sent messages, when mail clients append to the Sent mailbox. c.br = bufio.NewReader(c.tr) + c.tw = moxio.NewTraceWriter(c.log, "S: ", c) c.bw = bufio.NewWriter(c.tw) // Many IMAP connections use IDLE to wait for new incoming messages. We'll enable // keepalive to get a higher chance of the connection staying alive, or otherwise // detecting broken connections early. - xconn := c.conn - if xtls { - xconn = c.conn.(*tls.Conn).NetConn() - } - if tcpconn, ok := xconn.(*net.TCPConn); ok { + if tcpconn, ok := c.conn.(*net.TCPConn); ok { if err := tcpconn.SetKeepAlivePeriod(5 * time.Minute); err != nil { c.log.Errorx("setting keepalive period", err) } else if err := tcpconn.SetKeepAlive(true); err != nil { @@ -709,6 +717,12 @@ func serve(listenerName string, cid int64, tlsConfig *tls.Config, nc net.Conn, x } }() + if xtls { + // Start TLS on connection. We perform the handshake explicitly, so we can set a + // timeout, do client certificate authentication, log TLS details afterwards. + c.xtlsHandshakeAndAuthenticate(c.conn) + } + select { case <-mox.Shutdown.Done(): // ../rfc/9051:5381 @@ -742,7 +756,12 @@ func serve(listenerName string, cid int64, tlsConfig *tls.Config, nc net.Conn, x mox.Connections.Register(nc, "imap", listenerName) defer mox.Connections.Unregister(nc) - c.writelinef("* OK [CAPABILITY %s] mox imap", c.capabilities()) + if c.account != nil && !c.noPreauth { + c.state = stateAuthenticated + c.writelinef("* PREAUTH [CAPABILITY %s] mox imap welcomes %s", c.capabilities(), c.username) + } else { + c.writelinef("* OK [CAPABILITY %s] mox imap", c.capabilities()) + } for { c.command() @@ -756,6 +775,172 @@ func isClosed(err error) bool { return errors.Is(err, errIO) || errors.Is(err, errProtocol) || moxio.IsClosed(err) } +// makeTLSConfig makes a new tls config that is bound to the connection for +// possible client certificate authentication. +func (c *conn) makeTLSConfig() *tls.Config { + // We clone the config so we can set VerifyPeerCertificate below to a method bound + // to this connection. Earlier, we set session keys explicitly on the base TLS + // config, so they can be used for this connection too. + tlsConf := c.baseTLSConfig.Clone() + + // Allow client certificate authentication, for use with the sasl "external" + // authentication mechanism. + tlsConf.ClientAuth = tls.RequestClientCert + + // We verify the client certificate during the handshake. The TLS handshake is + // initiated explicitly for incoming connections and during starttls, so we can + // immediately extract the account name and address used for authentication. + tlsConf.VerifyPeerCertificate = c.tlsClientAuthVerifyPeerCert + + return tlsConf +} + +// tlsClientAuthVerifyPeerCert can be used as tls.Config.VerifyPeerCertificate, and +// sets authentication-related fields on conn. This is not called on resumed TLS +// connections. +func (c *conn) tlsClientAuthVerifyPeerCert(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error { + if len(rawCerts) == 0 { + return nil + } + + // If we had too many authentication failures from this IP, don't attempt + // authentication. If this is a new incoming connetion, it is closed after the TLS + // handshake. + if !mox.LimiterFailedAuth.CanAdd(c.remoteIP, time.Now(), 1) { + return nil + } + + cert, err := x509.ParseCertificate(rawCerts[0]) + if err != nil { + c.log.Debugx("parsing tls client certificate", err) + return err + } + if err := c.tlsClientAuthVerifyPeerCertParsed(cert); err != nil { + c.log.Debugx("verifying tls client certificate", err) + return fmt.Errorf("verifying client certificate: %w", err) + } + return nil +} + +// tlsClientAuthVerifyPeerCertParsed verifies a client certificate. Called both for +// fresh and resumed TLS connections. +func (c *conn) tlsClientAuthVerifyPeerCertParsed(cert *x509.Certificate) error { + if c.account != nil { + return fmt.Errorf("cannot authenticate with tls client certificate after previous authentication") + } + + authResult := "error" + defer func() { + metrics.AuthenticationInc("imap", "tlsclientauth", authResult) + if authResult == "ok" { + mox.LimiterFailedAuth.Reset(c.remoteIP, time.Now()) + } else { + mox.LimiterFailedAuth.Add(c.remoteIP, time.Now(), 1) + } + }() + + // For many failed auth attempts, slow down verification attempts. + if c.authFailed > 3 && authFailDelay > 0 { + mox.Sleep(mox.Context, time.Duration(c.authFailed-3)*authFailDelay) + } + c.authFailed++ // Compensated on success. + defer func() { + // On the 3rd failed authentication, start responding slowly. Successful auth will + // cause fast responses again. + if c.authFailed >= 3 { + c.setSlow(true) + } + }() + + shabuf := sha256.Sum256(cert.RawSubjectPublicKeyInfo) + fp := base64.RawURLEncoding.EncodeToString(shabuf[:]) + pubKey, err := store.TLSPublicKeyGet(context.TODO(), fp) + if err != nil { + if err == bstore.ErrAbsent { + authResult = "badcreds" + } + return fmt.Errorf("looking up tls public key with fingerprint %s: %v", fp, err) + } + + // Verify account exists and still matches address. + acc, _, err := store.OpenEmail(c.log, pubKey.LoginAddress) + if err != nil { + return fmt.Errorf("opening account for address %s for public key %s: %w", pubKey.LoginAddress, fp, err) + } + defer func() { + if acc != nil { + err := acc.Close() + c.xsanity(err, "close account") + } + }() + if acc.Name != pubKey.Account { + return fmt.Errorf("tls client public key %s is for account %s, but email address %s is for account %s", fp, pubKey.Account, pubKey.LoginAddress, acc.Name) + } + + authResult = "ok" + c.authFailed = 0 + c.noPreauth = pubKey.NoIMAPPreauth + c.account = acc + acc = nil // Prevent cleanup by defer. + c.username = pubKey.LoginAddress + c.comm = store.RegisterComm(c.account) + c.log.Debug("tls client authenticated with client certificate", + slog.String("fingerprint", fp), + slog.String("username", c.username), + slog.String("account", c.account.Name), + slog.Any("remote", c.remoteIP)) + return nil +} + +// xtlsHandshakeAndAuthenticate performs the TLS handshake, and verifies a client +// certificate if present. +func (c *conn) xtlsHandshakeAndAuthenticate(conn net.Conn) { + tlsConn := tls.Server(conn, c.makeTLSConfig()) + c.conn = tlsConn + c.tr = moxio.NewTraceReader(c.log, "C: ", c.conn) + c.br = bufio.NewReader(c.tr) + + cidctx := context.WithValue(mox.Context, mlog.CidKey, c.cid) + ctx, cancel := context.WithTimeout(cidctx, time.Minute) + defer cancel() + c.log.Debug("starting tls server handshake") + if err := tlsConn.HandshakeContext(ctx); err != nil { + panic(fmt.Errorf("tls handshake: %s (%w)", err, errIO)) + } + cancel() + + cs := tlsConn.ConnectionState() + if cs.DidResume && len(cs.PeerCertificates) > 0 { + // Verify client after session resumption. + err := c.tlsClientAuthVerifyPeerCertParsed(cs.PeerCertificates[0]) + if err != nil { + c.bwritelinef("* BYE [ALERT] Error verifying client certificate after TLS session resumption: %s", err) + panic(fmt.Errorf("tls verify client certificate after resumption: %s (%w)", err, errIO)) + } + } + + attrs := []slog.Attr{ + slog.Any("version", tlsVersion(cs.Version)), + slog.String("ciphersuite", tls.CipherSuiteName(cs.CipherSuite)), + slog.String("sni", cs.ServerName), + slog.Bool("resumed", cs.DidResume), + slog.Int("clientcerts", len(cs.PeerCertificates)), + } + if c.account != nil { + attrs = append(attrs, + slog.String("account", c.account.Name), + slog.String("username", c.username), + ) + } + c.log.Debug("tls handshake completed", attrs...) +} + +type tlsVersion uint16 + +func (v tlsVersion) String() string { + return strings.ReplaceAll(strings.ToLower(tls.VersionName(uint16(v))), " ", "-") +} + func (c *conn) command() { var tag, cmd, cmdlow string var p *parser @@ -1361,7 +1546,7 @@ func (c *conn) capabilities() string { caps := serverCapabilities // ../rfc/9051:1238 // We only allow starting without TLS when explicitly configured, in violation of RFC. - if !c.tls && c.tlsConfig != nil { + if !c.tls && c.baseTLSConfig != nil { caps += " STARTTLS" } if c.tls || c.noRequireSTARTTLS { @@ -1369,6 +1554,9 @@ func (c *conn) capabilities() string { } else { caps += " LOGINDISABLED" } + if c.tls && len(c.conn.(*tls.Conn).ConnectionState().PeerCertificates) > 0 { + caps += " AUTH=EXTERNAL" + } return caps } @@ -1454,7 +1642,7 @@ func (c *conn) cmdStarttls(tag, cmd string, p *parser) { if c.tls { xsyntaxErrorf("tls already active") // ../rfc/9051:1353 } - if c.tlsConfig == nil { + if c.baseTLSConfig == nil { xsyntaxErrorf("starttls not announced") } @@ -1468,30 +1656,20 @@ func (c *conn) cmdStarttls(tag, cmd string, p *parser) { // We add the cid to facilitate debugging in case of TLS connection failure. c.ok(tag, cmd+" ("+mox.ReceivedID(c.cid)+")") - cidctx := context.WithValue(mox.Context, mlog.CidKey, c.cid) - ctx, cancel := context.WithTimeout(cidctx, time.Minute) - defer cancel() - tlsConn := tls.Server(conn, c.tlsConfig) - c.log.Debug("starting tls server handshake") - if err := tlsConn.HandshakeContext(ctx); err != nil { - panic(fmt.Errorf("starttls handshake: %s (%w)", err, errIO)) - } - cancel() - tlsversion, ciphersuite := moxio.TLSInfo(tlsConn) - c.log.Debug("tls server handshake done", slog.String("tls", tlsversion), slog.String("ciphersuite", ciphersuite)) - - c.conn = tlsConn - c.tr = moxio.NewTraceReader(c.log, "C: ", c.conn) - c.tw = moxio.NewTraceWriter(c.log, "S: ", c) - c.br = bufio.NewReader(c.tr) - c.bw = bufio.NewWriter(c.tw) + c.xtlsHandshakeAndAuthenticate(conn) c.tls = true + + // We are not sending unsolicited CAPABILITIES for newly available authentication + // mechanisms, clients can't depend on us sending it and should ask it themselves. + // ../rfc/9051:1382 } // Authenticate using SASL. Supports multiple back and forths between client and // server to finish authentication, unlike LOGIN which is just a single // username/password. // +// We may already have ambient TLS credentials that have not been activated. +// // Status: Not authenticated. func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) { // Command: ../rfc/9051:1403 ../rfc/3501:1519 @@ -1519,7 +1697,7 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) { } }() - var authVariant string + var authVariant string // Only known strings, used in metrics. authResult := "error" defer func() { metrics.AuthenticationInc("imap", authVariant, authResult) @@ -1573,6 +1751,18 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) { return buf } + // The various authentication mechanisms set account and username. We may already + // have an account and username from TLS client authentication. Afterwards, we + // check that the account is the same. + var account *store.Account + var username string + defer func() { + if account != nil { + err := account.Close() + c.xsanity(err, "close account") + } + }() + switch strings.ToUpper(authType) { case "PLAIN": authVariant = "plain" @@ -1591,24 +1781,23 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) { xsyntaxErrorf("bad plain auth data, expected 3 nul-separated tokens, got %d tokens", len(plain)) } authz := string(plain[0]) - authc := string(plain[1]) + username = string(plain[1]) password := string(plain[2]) - if authz != "" && authz != authc { + if authz != "" && authz != username { xusercodeErrorf("AUTHORIZATIONFAILED", "cannot assume role") } - acc, err := store.OpenEmailAuth(c.log, authc, password) + var err error + account, err = store.OpenEmailAuth(c.log, username, password) if err != nil { if errors.Is(err, store.ErrUnknownCredentials) { authResult = "badcreds" - c.log.Info("authentication failed", slog.String("username", authc)) + c.log.Info("authentication failed", slog.String("username", username)) xusercodeErrorf("AUTHENTICATIONFAILED", "bad credentials") } xusercodeErrorf("", "error") } - c.account = acc - c.username = authc case "CRAM-MD5": authVariant = strings.ToLower(authType) @@ -1625,28 +1814,23 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) { if len(t) != 2 || len(t[1]) != 2*md5.Size { xsyntaxErrorf("malformed cram-md5 response") } - addr := t[0] - c.log.Debug("cram-md5 auth", slog.String("address", addr)) - acc, _, err := store.OpenEmail(c.log, addr) + username = t[0] + c.log.Debug("cram-md5 auth", slog.String("address", username)) + var err error + account, _, err = store.OpenEmail(c.log, username) if err != nil { if errors.Is(err, store.ErrUnknownCredentials) { - c.log.Info("failed authentication attempt", slog.String("username", addr), slog.Any("remote", c.remoteIP)) + c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP)) xusercodeErrorf("AUTHENTICATIONFAILED", "bad credentials") } xserverErrorf("looking up address: %v", err) } - defer func() { - if acc != nil { - err := acc.Close() - c.xsanity(err, "close account") - } - }() var ipadhash, opadhash hash.Hash - acc.WithRLock(func() { - err := acc.DB.Read(context.TODO(), func(tx *bstore.Tx) error { + account.WithRLock(func() { + err := account.DB.Read(context.TODO(), func(tx *bstore.Tx) error { password, err := bstore.QueryTx[store.Password](tx).Get() if err == bstore.ErrAbsent { - c.log.Info("failed authentication attempt", slog.String("username", addr), slog.Any("remote", c.remoteIP)) + c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP)) xusercodeErrorf("AUTHENTICATIONFAILED", "bad credentials") } if err != nil { @@ -1660,8 +1844,8 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) { xcheckf(err, "tx read") }) if ipadhash == nil || opadhash == nil { - c.log.Info("cram-md5 auth attempt without derived secrets set, save password again to store secrets", slog.String("username", addr)) - c.log.Info("failed authentication attempt", slog.String("username", addr), slog.Any("remote", c.remoteIP)) + c.log.Info("cram-md5 auth attempt without derived secrets set, save password again to store secrets", slog.String("username", username)) + c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP)) missingDerivedSecrets = true xusercodeErrorf("AUTHENTICATIONFAILED", "bad credentials") } @@ -1671,14 +1855,10 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) { opadhash.Write(ipadhash.Sum(nil)) digest := fmt.Sprintf("%x", opadhash.Sum(nil)) if digest != t[1] { - c.log.Info("failed authentication attempt", slog.String("username", addr), slog.Any("remote", c.remoteIP)) + c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP)) xusercodeErrorf("AUTHENTICATIONFAILED", "bad credentials") } - c.account = acc - acc = nil // Cancel cleanup. - c.username = addr - case "SCRAM-SHA-256-PLUS", "SCRAM-SHA-256", "SCRAM-SHA-1-PLUS", "SCRAM-SHA-1": // todo: improve handling of errors during scram. e.g. invalid parameters. should we abort the imap command, or continue until the end and respond with a scram-level error? // todo: use single implementation between ../imapserver/server.go and ../smtpserver/server.go @@ -1711,29 +1891,24 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) { c.log.Infox("scram protocol error", err, slog.Any("remote", c.remoteIP)) xuserErrorf("scram protocol error: %s", err) } - c.log.Debug("scram auth", slog.String("authentication", ss.Authentication)) - acc, _, err := store.OpenEmail(c.log, ss.Authentication) + username = ss.Authentication + c.log.Debug("scram auth", slog.String("authentication", username)) + account, _, err = store.OpenEmail(c.log, username) if err != nil { // todo: we could continue scram with a generated salt, deterministically generated // from the username. that way we don't have to store anything but attackers cannot // learn if an account exists. same for absent scram saltedpassword below. xuserErrorf("scram not possible") } - defer func() { - if acc != nil { - err := acc.Close() - c.xsanity(err, "close account") - } - }() - if ss.Authorization != "" && ss.Authorization != ss.Authentication { + if ss.Authorization != "" && ss.Authorization != username { xuserErrorf("authentication with authorization for different user not supported") } var xscram store.SCRAM - acc.WithRLock(func() { - err := acc.DB.Read(context.TODO(), func(tx *bstore.Tx) error { + account.WithRLock(func() { + err := account.DB.Read(context.TODO(), func(tx *bstore.Tx) error { password, err := bstore.QueryTx[store.Password](tx).Get() if err == bstore.ErrAbsent { - c.log.Info("failed authentication attempt", slog.String("username", ss.Authentication), slog.Any("remote", c.remoteIP)) + c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP)) xusercodeErrorf("AUTHENTICATIONFAILED", "bad credentials") } xcheckf(err, "fetching credentials") @@ -1747,7 +1922,7 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) { } if len(xscram.Salt) == 0 || xscram.Iterations == 0 || len(xscram.SaltedPassword) == 0 { missingDerivedSecrets = true - c.log.Info("scram auth attempt without derived secrets set, save password again to store secrets", slog.String("address", ss.Authentication)) + c.log.Info("scram auth attempt without derived secrets set, save password again to store secrets", slog.String("username", username)) xuserErrorf("scram not possible") } return nil @@ -1766,14 +1941,14 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) { c.readline(false) // Should be "*" for cancellation. if errors.Is(err, scram.ErrInvalidProof) { authResult = "badcreds" - c.log.Info("failed authentication attempt", slog.String("username", ss.Authentication), slog.Any("remote", c.remoteIP)) + c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP)) xusercodeErrorf("AUTHENTICATIONFAILED", "bad credentials") } else if errors.Is(err, scram.ErrChannelBindingsDontMatch) { authResult = "badchanbind" - c.log.Warn("bad channel binding during authentication, potential mitm", slog.String("username", ss.Authentication), slog.Any("remote", c.remoteIP)) + c.log.Warn("bad channel binding during authentication, potential mitm", slog.String("username", username), slog.Any("remote", c.remoteIP)) xusercodeErrorf("AUTHENTICATIONFAILED", "channel bindings do not match, potential mitm") } else if errors.Is(err, scram.ErrInvalidEncoding) { - c.log.Infox("bad scram protocol message", err, slog.String("username", ss.Authentication), slog.Any("remote", c.remoteIP)) + c.log.Infox("bad scram protocol message", err, slog.String("username", username), slog.Any("remote", c.remoteIP)) xuserErrorf("bad scram protocol message: %s", err) } xuserErrorf("server final: %w", err) @@ -1783,18 +1958,65 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) { // The message should be empty. todo: should we require it is empty? xreadContinuation() - c.account = acc - acc = nil // Cancel cleanup. - c.username = ss.Authentication + case "EXTERNAL": + authVariant = strings.ToLower(authType) + + // ../rfc/4422:1618 + buf := xreadInitial() + username = string(buf) + + if !c.tls { + xusercodeErrorf("AUTHENTICATIONFAILED", "tls required for tls client certificate authentication") + } + if c.account == nil { + xusercodeErrorf("AUTHENTICATIONFAILED", "missing client certificate, required for tls client certificate authentication") + } + + if username == "" { + username = c.username + } + var err error + account, _, err = store.OpenEmail(c.log, username) + xcheckf(err, "looking up username from tls client authentication") default: xuserErrorf("method not supported") } + // We may already have TLS credentials. They won't have been enabled, or we could + // get here due to the state machine that doesn't allow authentication while being + // authenticated. But allow another SASL authentication, but it has to be for the + // same account. It can be for a different username (email address) of the account. + if c.account != nil { + if account != c.account { + c.log.Debug("sasl authentication for different account than tls client authentication, aborting connection", + slog.String("saslmechanism", authVariant), + slog.String("saslaccount", account.Name), + slog.String("tlsaccount", c.account.Name), + slog.String("saslusername", username), + slog.String("tlsusername", c.username), + ) + xusercodeErrorf("AUTHENTICATIONFAILED", "authentication failed, tls client certificate public key belongs to another account") + } else if username != c.username { + c.log.Debug("sasl authentication for different username than tls client certificate authentication, switching to sasl username", + slog.String("saslmechanism", authVariant), + slog.String("saslusername", username), + slog.String("tlsusername", c.username), + slog.String("account", c.account.Name), + ) + } + } else { + c.account = account + account = nil // Prevent cleanup. + } + c.username = username + if c.comm == nil { + c.comm = store.RegisterComm(c.account) + } + c.setSlow(false) authResult = "ok" c.authFailed = 0 - c.comm = store.RegisterComm(c.account) c.state = stateAuthenticated c.writeresultf("%s OK [CAPABILITY %s] authenticate done", tag, c.capabilities()) } @@ -1808,13 +2030,18 @@ func (c *conn) cmdLogin(tag, cmd string, p *parser) { authResult := "error" defer func() { metrics.AuthenticationInc("imap", "login", authResult) + if authResult == "ok" { + mox.LimiterFailedAuth.Reset(c.remoteIP, time.Now()) + } else { + mox.LimiterFailedAuth.Add(c.remoteIP, time.Now(), 1) + } }() // todo: get this line logged with traceauth. the plaintext password is included on the command line, which we've already read (before dispatching to this function). // Request syntax: ../rfc/9051:6667 ../rfc/3501:4804 p.xspace() - userid := p.xastring() + username := p.xastring() p.xspace() password := p.xastring() p.xempty() @@ -1837,21 +2064,55 @@ func (c *conn) cmdLogin(tag, cmd string, p *parser) { } }() - acc, err := store.OpenEmailAuth(c.log, userid, password) + account, err := store.OpenEmailAuth(c.log, username, password) if err != nil { authResult = "badcreds" var code string if errors.Is(err, store.ErrUnknownCredentials) { code = "AUTHENTICATIONFAILED" - c.log.Info("failed authentication attempt", slog.String("username", userid), slog.Any("remote", c.remoteIP)) + c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP)) } xusercodeErrorf(code, "login failed") } - c.account = acc - c.username = userid + defer func() { + if account != nil { + err := account.Close() + c.log.Check(err, "close account") + } + }() + + // We may already have TLS credentials. They won't have been enabled, or we could + // get here due to the state machine that doesn't allow authentication while being + // authenticated. But allow another SASL authentication, but it has to be for the + // same account. It can be for a different username (email address) of the account. + if c.account != nil { + if account != c.account { + c.log.Debug("sasl authentication for different account than tls client authentication, aborting connection", + slog.String("saslmechanism", "login"), + slog.String("saslaccount", account.Name), + slog.String("tlsaccount", c.account.Name), + slog.String("saslusername", username), + slog.String("tlsusername", c.username), + ) + xusercodeErrorf("AUTHENTICATIONFAILED", "authentication failed, tls client certificate public key belongs to another account") + } else if username != c.username { + c.log.Debug("sasl authentication for different username than tls client certificate authentication, switching to sasl username", + slog.String("saslmechanism", "login"), + slog.String("saslusername", username), + slog.String("tlsusername", c.username), + slog.String("account", c.account.Name), + ) + } + } else { + c.account = account + account = nil // Prevent cleanup. + } + c.username = username + if c.comm == nil { + c.comm = store.RegisterComm(c.account) + } c.authFailed = 0 c.setSlow(false) - c.comm = store.RegisterComm(acc) c.state = stateAuthenticated authResult = "ok" c.writeresultf("%s OK [CAPABILITY %s] login done", tag, c.capabilities()) diff --git a/imapserver/server_test.go b/imapserver/server_test.go index a0564b228..c7f3783cb 100644 --- a/imapserver/server_test.go +++ b/imapserver/server_test.go @@ -162,6 +162,7 @@ type testconn struct { done chan struct{} serverConn net.Conn account *store.Account + switchStop func() // Result of last command. lastUntagged []imapclient.Untagged @@ -315,6 +316,9 @@ func (tc *testconn) close() { tc.client.Close() tc.serverConn.Close() tc.waitDone() + if tc.switchStop != nil { + tc.switchStop() + } } func xparseNumSet(s string) imapclient.NumSet { @@ -338,15 +342,23 @@ func startNoSwitchboard(t *testing.T) *testconn { const password0 = "te\u0301st \u00a0\u2002\u200a" // NFD and various unicode spaces. const password1 = "tést " // PRECIS normalized, with NFC. -func startArgs(t *testing.T, first, isTLS, allowLoginWithoutTLS, setPassword bool, accname string) *testconn { +func startArgs(t *testing.T, first, immediateTLS bool, allowLoginWithoutTLS, setPassword bool, accname string) *testconn { + return startArgsMore(t, first, immediateTLS, nil, nil, allowLoginWithoutTLS, false, setPassword, accname, nil) +} + +// todo: the parameters and usage are too much now. change to scheme similar to smtpserver, with params in a struct, and a separate method for init and making a connection. +func startArgsMore(t *testing.T, first, immediateTLS bool, serverConfig, clientConfig *tls.Config, allowLoginWithoutTLS, noCloseSwitchboard, setPassword bool, accname string, afterInit func() error) *testconn { limitersInit() // Reset rate limiters. - if first { - os.RemoveAll("../testdata/imap/data") - } mox.Context = ctxbg mox.ConfigStaticPath = filepath.FromSlash("../testdata/imap/mox.conf") mox.MustLoadConfig(true, false) + if first { + store.Close() // May not be open, we ignore error. + os.RemoveAll("../testdata/imap/data") + err := store.Init(ctxbg) + tcheck(t, err, "store init") + } acc, err := store.OpenAccount(pkglog, accname) tcheck(t, err, "open account") if setPassword { @@ -358,33 +370,55 @@ func startArgs(t *testing.T, first, isTLS, allowLoginWithoutTLS, setPassword boo switchStop = store.Switchboard() } + if afterInit != nil { + err := afterInit() + tcheck(t, err, "after init") + } + serverConn, clientConn := net.Pipe() - tlsConfig := &tls.Config{ - Certificates: []tls.Certificate{fakeCert(t)}, + if serverConfig == nil { + serverConfig = &tls.Config{ + Certificates: []tls.Certificate{fakeCert(t, false)}, + } } - if isTLS { - serverConn = tls.Server(serverConn, tlsConfig) - clientConn = tls.Client(clientConn, &tls.Config{InsecureSkipVerify: true}) + if immediateTLS { + if clientConfig == nil { + clientConfig = &tls.Config{InsecureSkipVerify: true} + } + clientConn = tls.Client(clientConn, clientConfig) } done := make(chan struct{}) connCounter++ cid := connCounter go func() { - serve("test", cid, tlsConfig, serverConn, isTLS, allowLoginWithoutTLS) - switchStop() + serve("test", cid, serverConfig, serverConn, immediateTLS, allowLoginWithoutTLS) + if !noCloseSwitchboard { + switchStop() + } close(done) }() client, err := imapclient.New(clientConn, true) tcheck(t, err, "new client") - return &testconn{t: t, conn: clientConn, client: client, done: done, serverConn: serverConn, account: acc} + tc := &testconn{t: t, conn: clientConn, client: client, done: done, serverConn: serverConn, account: acc} + if first && noCloseSwitchboard { + tc.switchStop = switchStop + } + return tc } -func fakeCert(t *testing.T) tls.Certificate { - privKey := ed25519.NewKeyFromSeed(make([]byte, ed25519.SeedSize)) // Fake key, don't use this for real! +func fakeCert(t *testing.T, randomkey bool) tls.Certificate { + seed := make([]byte, ed25519.SeedSize) + if randomkey { + cryptorand.Read(seed) + } + privKey := ed25519.NewKeyFromSeed(seed) // Fake key, don't use this for real! template := &x509.Certificate{ SerialNumber: big.NewInt(1), // Required field... + // Valid period is needed to get session resumption enabled. + NotBefore: time.Now().Add(-time.Minute), + NotAfter: time.Now().Add(time.Hour), } localCertBuf, err := x509.CreateCertificate(cryptorand.Reader, template, template, privKey.Public(), privKey) if err != nil { diff --git a/main.go b/main.go index b68249f20..ff19afa52 100644 --- a/main.go +++ b/main.go @@ -150,6 +150,11 @@ var commands = []struct { {"config address rm", cmdConfigAddressRemove}, {"config domain add", cmdConfigDomainAdd}, {"config domain rm", cmdConfigDomainRemove}, + {"config tlspubkey list", cmdConfigTlspubkeyList}, + {"config tlspubkey get", cmdConfigTlspubkeyGet}, + {"config tlspubkey add", cmdConfigTlspubkeyAdd}, + {"config tlspubkey rm", cmdConfigTlspubkeyRemove}, + {"config tlspubkey gen", cmdConfigTlspubkeyGen}, {"config alias list", cmdConfigAliasList}, {"config alias print", cmdConfigAliasPrint}, {"config alias add", cmdConfigAliasAdd}, @@ -921,6 +926,200 @@ func ctlcmdConfigAccountRemove(ctl *ctl, account string) { fmt.Println("account removed") } +func cmdConfigTlspubkeyList(c *cmd) { + c.params = "[account]" + c.help = `List TLS public keys for TLS client certificate authentication. + +If account is absent, the TLS public keys for all accounts are listed. +` + args := c.Parse() + var accountOpt string + if len(args) == 1 { + accountOpt = args[0] + } else if len(args) > 1 { + c.Usage() + } + + mustLoadConfig() + ctlcmdConfigTlspubkeyList(xctl(), accountOpt) +} + +func ctlcmdConfigTlspubkeyList(ctl *ctl, accountOpt string) { + ctl.xwrite("tlspubkeylist") + ctl.xwrite(accountOpt) + ctl.xreadok() + ctl.xstreamto(os.Stdout) +} + +func cmdConfigTlspubkeyGet(c *cmd) { + c.params = "fingerprint" + c.help = `Get a TLS public key for a fingerprint. + +Prints the type, name, account and address for the key, and the certificate in +PEM format. +` + args := c.Parse() + if len(args) != 1 { + c.Usage() + } + + mustLoadConfig() + ctlcmdConfigTlspubkeyGet(xctl(), args[0]) +} + +func ctlcmdConfigTlspubkeyGet(ctl *ctl, fingerprint string) { + ctl.xwrite("tlspubkeyget") + ctl.xwrite(fingerprint) + ctl.xreadok() + typ := ctl.xread() + name := ctl.xread() + account := ctl.xread() + address := ctl.xread() + noimappreauth := ctl.xread() + var b bytes.Buffer + ctl.xstreamto(&b) + buf := b.Bytes() + var block *pem.Block + if len(buf) != 0 { + block = &pem.Block{ + Type: "CERTIFICATE", + Bytes: buf, + } + } + + fmt.Printf("type: %s\nname: %s\naccount: %s\naddress: %s\nno imap preauth: %s\n", typ, name, account, address, noimappreauth) + if block != nil { + fmt.Printf("certificate:\n\n") + pem.Encode(os.Stdout, block) + } +} + +func cmdConfigTlspubkeyAdd(c *cmd) { + c.params = "address [name] < cert.pem" + c.help = `Add a TLS public key to the account of the given address. + +The public key is read from the certificate. + +The optional name is a human-readable descriptive name of the key. If absent, +the CommonName from the certificate is used. +` + var noimappreauth bool + c.flag.BoolVar(&noimappreauth, "no-imap-preauth", false, "Don't automatically switch new IMAP connections authenticated with this key to \"authenticated\" state after the TLS handshake. For working around clients that ignore the untagged IMAP PREAUTH response and try to authenticate while already authenticated.") + args := c.Parse() + var address, name string + if len(args) == 1 { + address = args[0] + } else if len(args) == 2 { + address, name = args[0], args[1] + } else { + c.Usage() + } + + buf, err := io.ReadAll(os.Stdin) + xcheckf(err, "reading from stdin") + block, _ := pem.Decode(buf) + if block == nil { + err = errors.New("no pem block found") + } else if block.Type != "CERTIFICATE" { + err = fmt.Errorf("unexpected type %q, expected CERTIFICATE", block.Type) + } + xcheckf(err, "parsing pem") + + mustLoadConfig() + ctlcmdConfigTlspubkeyAdd(xctl(), address, name, noimappreauth, block.Bytes) +} + +func ctlcmdConfigTlspubkeyAdd(ctl *ctl, address, name string, noimappreauth bool, certDER []byte) { + ctl.xwrite("tlspubkeyadd") + ctl.xwrite(address) + ctl.xwrite(name) + ctl.xwrite(fmt.Sprintf("%v", noimappreauth)) + ctl.xstreamfrom(bytes.NewReader(certDER)) + ctl.xreadok() +} + +func cmdConfigTlspubkeyRemove(c *cmd) { + c.params = "fingerprint" + c.help = `Remove TLS public key for fingerprint.` + args := c.Parse() + if len(args) != 1 { + c.Usage() + } + + mustLoadConfig() + ctlcmdConfigTlspubkeyRemove(xctl(), args[0]) +} + +func ctlcmdConfigTlspubkeyRemove(ctl *ctl, fingerprint string) { + ctl.xwrite("tlspubkeyrm") + ctl.xwrite(fingerprint) + ctl.xreadok() +} + +func cmdConfigTlspubkeyGen(c *cmd) { + c.params = "stem" + c.help = `Generate an ed25519 private key and minimal certificate for use a TLS public key and write to files starting with stem. + +The private key is written to $stem.$timestamp.ed25519privatekey.pkcs8.pem. +The certificate is written to $stem.$timestamp.certificate.pem. +The private key and certificate are also written to +$stem.$timestamp.ed25519privatekey-certificate.pem. + +The certificate can be added to an account with "mox config account tlspubkey add". + +The combined file can be used with "mox sendmail". + +The private key is also written to standard error in raw-url-base64-encoded +form, also for use with "mox sendmail". The fingerprint is written to standard +error too, for reference. +` + args := c.Parse() + if len(args) != 1 { + c.Usage() + } + + stem := args[0] + timestamp := time.Now().Format("200601021504") + prefix := stem + "." + timestamp + + seed := make([]byte, ed25519.SeedSize) + if _, err := cryptorand.Read(seed); err != nil { + panic(err) + } + privKey := ed25519.NewKeyFromSeed(seed) + privKeyBuf, err := x509.MarshalPKCS8PrivateKey(privKey) + xcheckf(err, "marshal private key as pkcs8") + var b bytes.Buffer + err = pem.Encode(&b, &pem.Block{Type: "PRIVATE KEY", Bytes: privKeyBuf}) + xcheckf(err, "marshal pkcs8 private key to pem") + privKeyBufPEM := b.Bytes() + + certBuf, tlsCert := xminimalCert(privKey) + b = bytes.Buffer{} + err = pem.Encode(&b, &pem.Block{Type: "CERTIFICATE", Bytes: certBuf}) + xcheckf(err, "marshal certificate to pem") + certBufPEM := b.Bytes() + + xwriteFile := func(p string, data []byte, what string) { + log.Printf("writing %s", p) + err = os.WriteFile(p, data, 0600) + xcheckf(err, "writing %s file: %v", what, err) + } + + xwriteFile(prefix+".ed25519privatekey.pkcs8.pem", privKeyBufPEM, "private key") + xwriteFile(prefix+".certificate.pem", certBufPEM, "certificate") + combinedPEM := append(append([]byte{}, privKeyBufPEM...), certBufPEM...) + xwriteFile(prefix+".ed25519privatekey-certificate.pem", combinedPEM, "combined private key and certificate") + + shabuf := sha256.Sum256(tlsCert.Leaf.RawSubjectPublicKeyInfo) + + _, err = fmt.Fprintf(os.Stderr, "ed25519 private key as raw-url-base64: %s\ned25519 public key fingerprint: %s\n", + base64.RawURLEncoding.EncodeToString(seed), + base64.RawURLEncoding.EncodeToString(shabuf[:]), + ) + xcheckf(err, "write private key and public key fingerprint") +} + func cmdConfigAddressAdd(c *cmd) { c.params = "address account" c.help = `Adds an address to an account and reloads the configuration. diff --git a/metrics/auth.go b/metrics/auth.go index 3014a9d0b..97f22f597 100644 --- a/metrics/auth.go +++ b/metrics/auth.go @@ -14,7 +14,7 @@ var ( }, []string{ "kind", // submission, imap, webmail, webapi, webaccount, webadmin (formerly httpaccount, httpadmin) - "variant", // login, plain, scram-sha-256, scram-sha-1, cram-md5, weblogin, websessionuse, httpbasic. + "variant", // login, plain, scram-sha-256, scram-sha-1, cram-md5, weblogin, websessionuse, httpbasic, tlsclientauth. // todo: we currently only use badcreds, but known baduser can be helpful "result", // ok, baduser, badpassword, badcreds, badchanbind, error, aborted }, diff --git a/mox-/lifecycle.go b/mox-/lifecycle.go index 752cc189a..ce601a557 100644 --- a/mox-/lifecycle.go +++ b/mox-/lifecycle.go @@ -142,7 +142,7 @@ func OpenPrivileged(path string) (*os.File, error) { // Shutdown is canceled when a graceful shutdown is initiated. SMTP, IMAP, periodic // processes should check this before starting a new operation. If this context is -// canaceled, the operation should not be started, and new connections/commands should +// canceled, the operation should not be started, and new connections/commands should // receive a message that the service is currently not available. var Shutdown context.Context var ShutdownCancel func() diff --git a/mox-/lookup.go b/mox-/lookup.go index 1fae8d523..61866d93c 100644 --- a/mox-/lookup.go +++ b/mox-/lookup.go @@ -14,7 +14,7 @@ var ( ErrAddressNotFound = errors.New("address not found") ) -// FindAccount looks up the account for localpart and domain. +// LookupAddress looks up the account for localpart and domain. // // Can return ErrDomainNotFound and ErrAddressNotFound. func LookupAddress(localpart smtp.Localpart, domain dns.Domain, allowPostmaster, allowAlias bool) (accountName string, alias *config.Alias, canonicalAddress string, dest config.Destination, rerr error) { diff --git a/mox-/sleep.go b/mox-/sleep.go index af56bc5a3..8bfe32256 100644 --- a/mox-/sleep.go +++ b/mox-/sleep.go @@ -9,11 +9,13 @@ import ( // // Used for a few places where sleep is used to push back on clients, but where // shutting down should abort the sleep. -func Sleep(ctx context.Context, d time.Duration) { +func Sleep(ctx context.Context, d time.Duration) (ctxDone bool) { t := time.NewTicker(d) defer t.Stop() select { case <-t.C: + return false case <-ctx.Done(): + return true } } diff --git a/mox-/tlsalert.go b/mox-/tlsalert.go new file mode 100644 index 000000000..eb7f4d092 --- /dev/null +++ b/mox-/tlsalert.go @@ -0,0 +1,23 @@ +package mox + +import ( + "errors" + "net" + "reflect" +) + +func AsTLSAlert(err error) (alert uint8, ok bool) { + // If the remote client aborts the connection, it can send an alert indicating why. + // crypto/tls gives us a net.OpError with "Op" set to "remote error", an an Err + // with the unexported type "alert", a uint8. So we try to read it. + + var opErr *net.OpError + if !errors.As(err, &opErr) || opErr.Op != "remote error" || opErr.Err == nil { + return + } + v := reflect.ValueOf(opErr.Err) + if v.Kind() != reflect.Uint8 || v.Type().Name() != "alert" { + return + } + return uint8(v.Uint()), true +} diff --git a/mox-/tlssessionticket.go b/mox-/tlssessionticket.go new file mode 100644 index 000000000..d2574b3d1 --- /dev/null +++ b/mox-/tlssessionticket.go @@ -0,0 +1,51 @@ +package mox + +import ( + "context" + cryptorand "crypto/rand" + "crypto/tls" + "time" + + "github.com/mjl-/mox/mlog" +) + +// StartTLSSessionTicketKeyRefresher sets session keys on the TLS config, and +// rotates them periodically. +// +// Useful for TLS configs that are being cloned for each connection. The +// automatically managed keys would happen in the cloned config, and not make +// it back to the base config. +func StartTLSSessionTicketKeyRefresher(ctx context.Context, log mlog.Log, c *tls.Config) { + var keys [][32]byte + first := make(chan struct{}) + + // Similar to crypto/tls, we rotate keys once a day. Previous keys stay valid for 7 + // days. We currently only store ticket keys in memory, so a restart invalidates + // previous session tickets. We could store them in the future. + go func() { + for { + var nk [32]byte + if _, err := cryptorand.Read(nk[:]); err != nil { + panic(err) + } + if len(keys) > 7 { + keys = keys[:7] + } + keys = append([][32]byte{nk}, keys...) + c.SetSessionTicketKeys(keys) + + if first != nil { + first <- struct{}{} + first = nil + } + + ctxDone := Sleep(ctx, 24*time.Hour) + if ctxDone { + break + } + log.Info("rotating tls session keys") + } + }() + + <-first +} diff --git a/sasl/sasl.go b/sasl/sasl.go index f32197fdf..f49063cca 100644 --- a/sasl/sasl.go +++ b/sasl/sasl.go @@ -286,3 +286,31 @@ func (a *clientSCRAMSHA) Next(fromServer []byte) (toServer []byte, last bool, re return nil, false, fmt.Errorf("invalid step %d", a.step) } } + +type clientExternal struct { + Username string + step int +} + +var _ Client = (*clientExternal)(nil) + +// NewClientExternal returns a client for SASL EXTERNAL authentication. +// +// Username is optional. +func NewClientExternal(username string) Client { + return &clientExternal{username, 0} +} + +func (a *clientExternal) Info() (name string, hasCleartextCredentials bool) { + return "EXTERNAL", false +} + +func (a *clientExternal) Next(fromServer []byte) (toServer []byte, last bool, rerr error) { + defer func() { a.step++ }() + switch a.step { + case 0: + return []byte(a.Username), true, nil + default: + return nil, false, fmt.Errorf("invalid step %d", a.step) + } +} diff --git a/sendmail.go b/sendmail.go index 041e71ae0..fd5d12aa6 100644 --- a/sendmail.go +++ b/sendmail.go @@ -3,11 +3,18 @@ package main import ( "bufio" "context" + "crypto" + "crypto/ed25519" + cryptorand "crypto/rand" "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/pem" "errors" "fmt" "io" "log" + "math/big" "net" "os" "path/filepath" @@ -26,17 +33,24 @@ import ( ) var submitconf struct { - LocalHostname string `sconf-doc:"Hosts don't always have an FQDN, set it explicitly, for EHLO."` - Host string `sconf-doc:"Host to dial for delivery, e.g. mail.."` - Port int `sconf-doc:"Port to dial for delivery, e.g. 465 for submissions, 587 for submission, or perhaps 25 for smtp."` - TLS bool `sconf-doc:"Connect with TLS. Usually for connections to port 465."` - STARTTLS bool `sconf-doc:"After starting in plain text, use STARTTLS to enable TLS. For port 587 and 25."` - Username string `sconf-doc:"For SMTP authentication."` - Password string `sconf-doc:"For password-based SMTP authentication, e.g. SCRAM-SHA-256-PLUS, CRAM-MD5, PLAIN."` - AuthMethod string `sconf-doc:"If set, only attempt this authentication mechanism. E.g. SCRAM-SHA-256-PLUS, SCRAM-SHA-256, SCRAM-SHA-1-PLUS, SCRAM-SHA-1, CRAM-MD5, PLAIN. If not set, any mutually supported algorithm can be used, in order listed, from most to least secure. It is recommended to specify the strongest authentication mechanism known to be implemented by the server, to prevent mechanism downgrade attacks."` - From string `sconf-doc:"Address for MAIL FROM in SMTP and From-header in message."` - DefaultDestination string `sconf:"optional" sconf-doc:"Used when specified address does not contain an @ and may be a local user (eg root)."` - RequireTLS RequireTLSOption `sconf:"optional" sconf-doc:"If yes, submission server must implement SMTP REQUIRETLS extension, and connection to submission server must use verified TLS. If no, a TLS-Required header with value no is added to the message, allowing fallback to unverified TLS or plain text delivery despite recpient domain policies. By default, the submission server will follow the policies of the recipient domain (MTA-STS and/or DANE), and apply unverified opportunistic TLS with STARTTLS."` + LocalHostname string `sconf-doc:"Hosts don't always have an FQDN, set it explicitly, for EHLO."` + Host string `sconf-doc:"Host to dial for delivery, e.g. mail.."` + Port int `sconf-doc:"Port to dial for delivery, e.g. 465 for submissions, 587 for submission, or perhaps 25 for smtp."` + TLS bool `sconf-doc:"Connect with TLS. Usually for connections to port 465."` + STARTTLS bool `sconf-doc:"After starting in plain text, use STARTTLS to enable TLS. For port 587 and 25."` + TLSInsecureSkipVerify bool `sconf:"optional" sconf-doc:"If true, do not verify the server TLS identity."` + Username string `sconf-doc:"For SMTP authentication."` + Password string `sconf:"optional" sconf-doc:"For password-based SMTP authentication, e.g. SCRAM-SHA-256-PLUS, CRAM-MD5, PLAIN."` + ClientAuthEd25519PrivateKey string `sconf:"optional" sconf-doc:"If set, used for TLS client authentication with a certificate. The private key must be a raw-url-base64-encoded ed25519 key. A basic certificate is composed automatically. The server must use the public key of a certificate to identify/verify users."` + ClientAuthCertPrivateKeyPEMFile string `sconf:"optional" sconf-doc:"If set, an absolute path to a PEM file containing both a PKCS#8 unencrypted private key and a certificate. Used for TLS client authentication."` + AuthMethod string `sconf-doc:"If set, only attempt this authentication mechanism. E.g. EXTERNAL (for TLS client authentication), SCRAM-SHA-256-PLUS, SCRAM-SHA-256, SCRAM-SHA-1-PLUS, SCRAM-SHA-1, CRAM-MD5, PLAIN. If not set, any mutually supported algorithm can be used, in order listed, from most to least secure. It is recommended to specify the strongest authentication mechanism known to be implemented by the server, to prevent mechanism downgrade attacks. Exactly one of Password, ClientAuthEd25519PrivateKey and ClientAuthCertPrivateKeyPEMFile must be set."` + From string `sconf-doc:"Address for MAIL FROM in SMTP and From-header in message."` + DefaultDestination string `sconf:"optional" sconf-doc:"Used when specified address does not contain an @ and may be a local user (eg root)."` + RequireTLS RequireTLSOption `sconf:"optional" sconf-doc:"If yes, submission server must implement SMTP REQUIRETLS extension, and connection to submission server must use verified TLS. If no, a TLS-Required header with value no is added to the message, allowing fallback to unverified TLS or plain text delivery despite recpient domain policies. By default, the submission server will follow the policies of the recipient domain (MTA-STS and/or DANE), and apply unverified opportunistic TLS with STARTTLS."` + + // For TLS client authentication with a certificate. Either from + // ClientAuthEd25519PrivateKey or ClientAuthCertPrivateKeyPEMFile. + clientCert *tls.Certificate } type RequireTLSOption string @@ -128,6 +142,71 @@ binary should be setgid that group: err := sconf.ParseFile(confPath, &submitconf) xcheckf(err, "parsing config") + var secrets []string + for _, s := range []string{submitconf.Password, submitconf.ClientAuthEd25519PrivateKey, submitconf.ClientAuthCertPrivateKeyPEMFile} { + if s != "" { + secrets = append(secrets, s) + } + } + if len(secrets) != 1 { + xcheckf(fmt.Errorf("got passwords/keys %s, need exactly one", strings.Join(secrets, ", ")), "checking passwords/keys") + } + if submitconf.ClientAuthEd25519PrivateKey != "" { + seed, err := base64.RawURLEncoding.DecodeString(submitconf.ClientAuthEd25519PrivateKey) + xcheckf(err, "parsing ed25519 private key") + if len(seed) != ed25519.SeedSize { + xcheckf(fmt.Errorf("got %d bytes, need %d", len(seed), ed25519.SeedSize), "parsing ed25519 private key") + } + privKey := ed25519.NewKeyFromSeed(seed) + _, cert := xminimalCert(privKey) + submitconf.clientCert = &cert + } else if submitconf.ClientAuthCertPrivateKeyPEMFile != "" { + pemBuf, err := os.ReadFile(submitconf.ClientAuthCertPrivateKeyPEMFile) + xcheckf(err, "reading pem file") + var cert tls.Certificate + for { + block, rest := pem.Decode(pemBuf) + if block == nil && len(rest) != 0 { + log.Printf("xxx, leftover data %q", rest) + log.Fatalf("leftover data in pem file") + } else if block == nil { + break + } + switch block.Type { + case "CERTIFICATE": + c, err := x509.ParseCertificate(block.Bytes) + xcheckf(err, "parsing certificate") + if cert.Leaf == nil { + cert.Leaf = c + } + cert.Certificate = append(cert.Certificate, block.Bytes) + case "PRIVATE KEY": + if cert.PrivateKey != nil { + log.Fatalf("cannot handle multiple private keys") + } + privKey, err := x509.ParsePKCS8PrivateKey(block.Bytes) + xcheckf(err, "parsing private key") + cert.PrivateKey = privKey + default: + log.Fatalf("unrecognized pem type %q, only CERTIFICATE and PRIVATE KEY allowed", block.Type) + } + pemBuf = rest + } + if len(cert.Certificate) == 0 { + log.Fatalf("no certificate(s) found in pem file") + } + if cert.PrivateKey == nil { + log.Fatalf("no private key found in pem file") + } + type cryptoPublicKey interface { + Equal(x crypto.PublicKey) bool + } + if !cert.PrivateKey.(crypto.Signer).Public().(cryptoPublicKey).Equal(cert.Leaf.PublicKey) { + log.Fatalf("certificate public key does not match with private key") + } + submitconf.clientCert = &cert + } + var recipient string if len(args) == 1 && !tflag { recipient = args[0] @@ -257,6 +336,8 @@ binary should be setgid that group: auth := func(mechanisms []string, cs *tls.ConnectionState) (sasl.Client, error) { // Check explicitly configured mechanisms. switch submitconf.AuthMethod { + case "EXTERNAL": + return sasl.NewClientExternal(submitconf.Username), nil case "SCRAM-SHA-256-PLUS": if cs == nil { return nil, fmt.Errorf("scram plus authentication mechanism requires tls") @@ -278,7 +359,9 @@ binary should be setgid that group: } // Try the defaults, from more to less secure. - if cs != nil && slices.Contains(mechanisms, "SCRAM-SHA-256-PLUS") { + if cs != nil && submitconf.clientCert != nil { + return sasl.NewClientExternal(submitconf.Username), nil + } else if cs != nil && slices.Contains(mechanisms, "SCRAM-SHA-256-PLUS") { return sasl.NewClientSCRAMSHA256PLUS(submitconf.Username, submitconf.Password, *cs), nil } else if slices.Contains(mechanisms, "SCRAM-SHA-256") { return sasl.NewClientSCRAMSHA256(submitconf.Username, submitconf.Password, true), nil @@ -308,6 +391,9 @@ binary should be setgid that group: } else if submitconf.RequireTLS == RequireTLSYes { xsavecheckf(errors.New("cannot submit with requiretls enabled without tls to submission server"), "checking tls configuration") } + if submitconf.TLSInsecureSkipVerify { + tlsPKIX = false + } ourHostname, err := dns.ParseDomain(submitconf.LocalHostname) xsavecheckf(err, "parsing our local hostname") @@ -320,8 +406,9 @@ binary should be setgid that group: // todo: implement SRV and DANE, allowing for a simpler config file (just the email address & password) opts := smtpclient.Opts{ - Auth: auth, - RootCAs: mox.Conf.Static.TLS.CertPool, + Auth: auth, + RootCAs: mox.Conf.Static.TLS.CertPool, + ClientCert: submitconf.clientCert, } client, err := smtpclient.New(ctx, c.log.Logger, conn, tlsMode, tlsPKIX, ourHostname, remoteHostname, opts) xsavecheckf(err, "open smtp session") @@ -333,3 +420,20 @@ binary should be setgid that group: log.Printf("closing smtp session after message was sent: %v", err) } } + +func xminimalCert(privKey ed25519.PrivateKey) ([]byte, tls.Certificate) { + template := &x509.Certificate{ + // Required field. + SerialNumber: big.NewInt(time.Now().Unix()), + } + certBuf, err := x509.CreateCertificate(cryptorand.Reader, template, template, privKey.Public(), privKey) + xcheckf(err, "creating minimal certificate") + cert, err := x509.ParseCertificate(certBuf) + xcheckf(err, "parsing certificate") + c := tls.Certificate{ + Certificate: [][]byte{certBuf}, + PrivateKey: privKey, + Leaf: cert, + } + return certBuf, c +} diff --git a/serve.go b/serve.go index aef5406cf..18b8dd3bf 100644 --- a/serve.go +++ b/serve.go @@ -82,6 +82,10 @@ func start(mtastsdbRefresher, sendDMARCReports, sendTLSReports, skipForkExec boo return fmt.Errorf("dmarcdb init: %s", err) } + if err := store.Init(mox.Context); err != nil { + return fmt.Errorf("store init: %s", err) + } + done := make(chan struct{}) // Goroutines for messages and webhooks, and cleaners. if err := queue.Start(dns.StrictResolver{Pkg: "queue"}, done); err != nil { return fmt.Errorf("queue start: %s", err) diff --git a/smtpclient/client.go b/smtpclient/client.go index 238f06530..ee7380b79 100644 --- a/smtpclient/client.go +++ b/smtpclient/client.go @@ -109,10 +109,11 @@ type Client struct { tlsVerifyPKIX bool ignoreTLSVerifyErrors bool rootCAs *x509.CertPool - remoteHostname dns.Domain // TLS with SNI and name verification. - daneRecords []adns.TLSA // For authenticating (START)TLS connection. - daneMoreHostnames []dns.Domain // Additional allowed names in TLS certificate for DANE-TA. - daneVerifiedRecord *adns.TLSA // If non-nil, then will be set to verified DANE record if any. + remoteHostname dns.Domain // TLS with SNI and name verification. + daneRecords []adns.TLSA // For authenticating (START)TLS connection. + daneMoreHostnames []dns.Domain // Additional allowed names in TLS certificate for DANE-TA. + daneVerifiedRecord *adns.TLSA // If non-nil, then will be set to verified DANE record if any. + clientCert *tls.Certificate // If non-nil, tls client authentication is done. // TLS connection success/failure are added. These are always non-nil, regardless // of what was passed in opts. It lets us unconditionally dereference them. @@ -226,6 +227,9 @@ type Opts struct { // If not nil, used instead of the system default roots for TLS PKIX verification. RootCAs *x509.CertPool + // If set, the TLS client certificate authentication is done. + ClientCert *tls.Certificate + // TLS verification successes/failures is added to these TLS reporting results. // Once the STARTTLS handshake is attempted, a successful/failed connection is // tracked. @@ -281,6 +285,7 @@ func New(ctx context.Context, elog *slog.Logger, conn net.Conn, tlsMode TLSMode, daneRecords: opts.DANERecords, daneMoreHostnames: opts.DANEMoreHostnames, daneVerifiedRecord: opts.DANEVerifiedRecord, + clientCert: opts.ClientCert, lastlog: time.Now(), cmds: []string{"(none)"}, recipientDomainResult: ensureResult(opts.RecipientDomainResult), @@ -417,12 +422,18 @@ func (c *Client) tlsConfig() *tls.Config { return nil } + var certs []tls.Certificate + if c.clientCert != nil { + certs = []tls.Certificate{*c.clientCert} + } + return &tls.Config{ ServerName: c.remoteHostname.ASCII, // For SNI. // todo: possibly accept older TLS versions for TLSOpportunistic? or would our private key be at risk? MinVersion: tls.VersionTLS12, // ../rfc/8996:31 ../rfc/8997:66 InsecureSkipVerify: true, // VerifyConnection below is called and will do all verification. VerifyConnection: verifyConnection, + Certificates: certs, } } diff --git a/smtpserver/server.go b/smtpserver/server.go index 6e112667d..f933a8f4d 100644 --- a/smtpserver/server.go +++ b/smtpserver/server.go @@ -12,6 +12,7 @@ import ( "crypto/sha1" "crypto/sha256" "crypto/tls" + "crypto/x509" "encoding/base64" "errors" "fmt" @@ -22,7 +23,6 @@ import ( "net" "net/textproto" "os" - "reflect" "runtime/debug" "slices" "sort" @@ -272,8 +272,14 @@ func listen1(protocol, name, ip string, port int, hostname dns.Domain, tlsConfig if err != nil { log.Fatalx("smtp: listen for smtp", err, slog.String("protocol", protocol), slog.String("listener", name)) } - if xtls { - ln = tls.NewListener(ln, tlsConfig) + + // Each listener gets its own copy of the config, so session keys between different + // ports on same listener aren't shared. We rotate session keys explicitly in this + // base TLS config because each connection clones the TLS config before using. The + // base TLS config would never get automatically managed/rotated session keys. + if tlsConfig != nil { + tlsConfig = tlsConfig.Clone() + mox.StartTLSSessionTicketKeyRefresher(mox.Shutdown, log, tlsConfig) } serve := func() { @@ -320,7 +326,7 @@ type conn struct { slow bool // If set, reads are done with a 1 second sleep, and writes are done 1 byte at a time, to keep spammers busy. lastlog time.Time // Used for printing the delta time since the previous logging for this connection. submission bool // ../rfc/6409:19 applies - tlsConfig *tls.Config + baseTLSConfig *tls.Config localIP net.IP remoteIP net.IP hostname dns.Domain @@ -342,6 +348,8 @@ type conn struct { ehlo bool // If set, we had EHLO instead of HELO. authFailed int // Number of failed auth attempts. For slowing down remote with many failures. + authSASL bool // Whether SASL authentication was done. + authTLS bool // Whether we did TLS client cert authentication. username string // Only when authenticated. account *store.Account // Only when authenticated. @@ -385,17 +393,208 @@ func isClosed(err error) bool { return errors.Is(err, errIO) || moxio.IsClosed(err) } +// makeTLSConfig makes a new tls config that is bound to the connection for +// possible client certificate authentication in case of submission. +func (c *conn) makeTLSConfig() *tls.Config { + if !c.submission { + return c.baseTLSConfig + } + + // We clone the config so we can set VerifyPeerCertificate below to a method bound + // to this connection. Earlier, we set session keys explicitly on the base TLS + // config, so they can be used for this connection too. + tlsConf := c.baseTLSConfig.Clone() + + // Allow client certificate authentication, for use with the sasl "external" + // authentication mechanism. + tlsConf.ClientAuth = tls.RequestClientCert + + // We verify the client certificate during the handshake. The TLS handshake is + // initiated explicitly for incoming connections and during starttls, so we can + // immediately extract the account name and address used for authentication. + tlsConf.VerifyPeerCertificate = c.tlsClientAuthVerifyPeerCert + + return tlsConf +} + +// tlsClientAuthVerifyPeerCert can be used as tls.Config.VerifyPeerCertificate, and +// sets authentication-related fields on conn. This is not called on resumed TLS +// connections. +func (c *conn) tlsClientAuthVerifyPeerCert(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error { + if len(rawCerts) == 0 { + return nil + } + + // If we had too many authentication failures from this IP, don't attempt + // authentication. If this is a new incoming connetion, it is closed after the TLS + // handshake. + if !mox.LimiterFailedAuth.CanAdd(c.remoteIP, time.Now(), 1) { + return nil + } + + cert, err := x509.ParseCertificate(rawCerts[0]) + if err != nil { + c.log.Debugx("parsing tls client certificate", err) + return err + } + if err := c.tlsClientAuthVerifyPeerCertParsed(cert); err != nil { + c.log.Debugx("verifying tls client certificate", err) + return fmt.Errorf("verifying client certificate: %w", err) + } + return nil +} + +// tlsClientAuthVerifyPeerCertParsed verifies a client certificate. Called both for +// fresh and resumed TLS connections. +func (c *conn) tlsClientAuthVerifyPeerCertParsed(cert *x509.Certificate) error { + if c.account != nil { + return fmt.Errorf("cannot authenticate with tls client certificate after previous authentication") + } + + authResult := "error" + defer func() { + metrics.AuthenticationInc("submission", "tlsclientauth", authResult) + if authResult == "ok" { + mox.LimiterFailedAuth.Reset(c.remoteIP, time.Now()) + } else { + mox.LimiterFailedAuth.Add(c.remoteIP, time.Now(), 1) + } + }() + + // For many failed auth attempts, slow down verification attempts. + if c.authFailed > 3 && authFailDelay > 0 { + mox.Sleep(mox.Context, time.Duration(c.authFailed-3)*authFailDelay) + } + c.authFailed++ // Compensated on success. + defer func() { + // On the 3rd failed authentication, start responding slowly. Successful auth will + // cause fast responses again. + if c.authFailed >= 3 { + c.setSlow(true) + } + }() + + shabuf := sha256.Sum256(cert.RawSubjectPublicKeyInfo) + fp := base64.RawURLEncoding.EncodeToString(shabuf[:]) + pubKey, err := store.TLSPublicKeyGet(context.TODO(), fp) + if err != nil { + if err == bstore.ErrAbsent { + authResult = "badcreds" + } + return fmt.Errorf("looking up tls public key with fingerprint %s: %v", fp, err) + } + + // Verify account exists and still matches address. + acc, _, err := store.OpenEmail(c.log, pubKey.LoginAddress) + if err != nil { + return fmt.Errorf("opening account for address %s for public key %s: %w", pubKey.LoginAddress, fp, err) + } + defer func() { + if acc != nil { + err := acc.Close() + c.log.Check(err, "close account") + } + }() + if acc.Name != pubKey.Account { + return fmt.Errorf("tls client public key %s is for account %s, but email address %s is for account %s", fp, pubKey.Account, pubKey.LoginAddress, acc.Name) + } + + authResult = "ok" + c.authFailed = 0 + c.account = acc + acc = nil // Prevent cleanup by defer. + c.username = pubKey.LoginAddress + c.authTLS = true + c.log.Debug("tls client authenticated with client certificate", + slog.String("fingerprint", fp), + slog.String("username", c.username), + slog.String("account", c.account.Name), + slog.Any("remote", c.remoteIP)) + return nil +} + +// xtlsHandshakeAndAuthenticate performs the TLS handshake, and verifies a client +// certificate if present. +func (c *conn) xtlsHandshakeAndAuthenticate(conn net.Conn) { + tlsConn := tls.Server(conn, c.makeTLSConfig()) + c.conn = tlsConn + + cidctx := context.WithValue(mox.Context, mlog.CidKey, c.cid) + ctx, cancel := context.WithTimeout(cidctx, time.Minute) + defer cancel() + c.log.Debug("starting tls server handshake") + if !c.submission { + metricDeliveryStarttls.Inc() + } + if err := tlsConn.HandshakeContext(ctx); err != nil { + if !c.submission { + // Errors from crypto/tls mostly aren't typed. We'll have to look for strings... + reason := "other" + if errors.Is(err, io.EOF) { + reason = "eof" + } else if alert, ok := mox.AsTLSAlert(err); ok { + reason = tlsrpt.FormatAlert(alert) + } else { + s := err.Error() + if strings.Contains(s, "tls: client offered only unsupported versions") { + reason = "unsupportedversions" + } else if strings.Contains(s, "tls: first record does not look like a TLS handshake") { + reason = "nottls" + } else if strings.Contains(s, "tls: unsupported SSLv2 handshake received") { + reason = "sslv2" + } + } + metricDeliveryStarttlsErrors.WithLabelValues(reason).Inc() + } + panic(fmt.Errorf("tls handshake: %s (%w)", err, errIO)) + } + cancel() + + cs := tlsConn.ConnectionState() + if cs.DidResume && len(cs.PeerCertificates) > 0 { + // Verify client after session resumption. + err := c.tlsClientAuthVerifyPeerCertParsed(cs.PeerCertificates[0]) + if err != nil { + panic(fmt.Errorf("tls verify client certificate after resumption: %s (%w)", err, errIO)) + } + } + + attrs := []slog.Attr{ + slog.Any("version", tlsVersion(cs.Version)), + slog.String("ciphersuite", tls.CipherSuiteName(cs.CipherSuite)), + slog.String("sni", cs.ServerName), + slog.Bool("resumed", cs.DidResume), + slog.Int("clientcerts", len(cs.PeerCertificates)), + } + if c.account != nil { + attrs = append(attrs, + slog.String("account", c.account.Name), + slog.String("username", c.username), + ) + } + c.log.Debug("tls handshake completed", attrs...) +} + +type tlsVersion uint16 + +func (v tlsVersion) String() string { + return strings.ReplaceAll(strings.ToLower(tls.VersionName(uint16(v))), " ", "-") +} + // completely reset connection state as if greeting has just been sent. // ../rfc/3207:210 func (c *conn) reset() { c.ehlo = false c.hello = dns.IPDomain{} - c.username = "" - if c.account != nil { - err := c.account.Close() - c.log.Check(err, "closing account") + if !c.authTLS { + c.username = "" + if c.account != nil { + err := c.account.Close() + c.log.Check(err, "closing account") + } + c.account = nil } - c.account = nil + c.authSASL = false c.rset() } @@ -593,7 +792,7 @@ func (c *conn) writelinef(format string, args ...any) { var cleanClose struct{} // Sentinel value for panic/recover indicating clean close of connection. -func serve(listenerName string, cid int64, hostname dns.Domain, tlsConfig *tls.Config, nc net.Conn, resolver dns.Resolver, submission, tls bool, maxMessageSize int64, requireTLSForAuth, requireTLSForDelivery, requireTLS bool, dnsBLs []dns.Domain, firstTimeSenderDelay time.Duration) { +func serve(listenerName string, cid int64, hostname dns.Domain, tlsConfig *tls.Config, nc net.Conn, resolver dns.Resolver, submission, xtls bool, maxMessageSize int64, requireTLSForAuth, requireTLSForDelivery, requireTLS bool, dnsBLs []dns.Domain, firstTimeSenderDelay time.Duration) { var localIP, remoteIP net.IP if a, ok := nc.LocalAddr().(*net.TCPAddr); ok { localIP = a.IP @@ -613,11 +812,11 @@ func serve(listenerName string, cid int64, hostname dns.Domain, tlsConfig *tls.C origConn: nc, conn: nc, submission: submission, - tls: tls, + tls: xtls, extRequireTLS: requireTLS, resolver: resolver, lastlog: time.Now(), - tlsConfig: tlsConfig, + baseTLSConfig: tlsConfig, localIP: localIP, remoteIP: remoteIP, hostname: hostname, @@ -643,8 +842,8 @@ func serve(listenerName string, cid int64, hostname dns.Domain, tlsConfig *tls.C return l }) c.tr = moxio.NewTraceReader(c.log, "RC: ", c) - c.tw = moxio.NewTraceWriter(c.log, "LS: ", c) c.r = bufio.NewReader(c.tr) + c.tw = moxio.NewTraceWriter(c.log, "LS: ", c) c.w = bufio.NewWriter(c.tw) metricConnection.WithLabelValues(c.kind()).Inc() @@ -652,7 +851,7 @@ func serve(listenerName string, cid int64, hostname dns.Domain, tlsConfig *tls.C slog.Any("remote", c.conn.RemoteAddr()), slog.Any("local", c.conn.LocalAddr()), slog.Bool("submission", submission), - slog.Bool("tls", tls), + slog.Bool("tls", xtls), slog.String("listener", listenerName)) defer func() { @@ -677,6 +876,12 @@ func serve(listenerName string, cid int64, hostname dns.Domain, tlsConfig *tls.C } }() + if xtls { + // Start TLS on connection. We perform the handshake explicitly, so we can set a + // timeout, do client certificate authentication, log TLS details afterwards. + c.xtlsHandshakeAndAuthenticate(c.conn) + } + select { case <-mox.Shutdown.Done(): // ../rfc/5321:2811 ../rfc/5321:1666 ../rfc/3463:420 @@ -905,7 +1110,7 @@ func (c *conn) cmdHello(p *parser, ehlo bool) { c.bwritelinef("250-PIPELINING") // ../rfc/2920:108 c.bwritelinef("250-SIZE %d", c.maxMessageSize) // ../rfc/1870:70 // ../rfc/3207:237 - if !c.tls && c.tlsConfig != nil { + if !c.tls && c.baseTLSConfig != nil { // ../rfc/3207:90 c.bwritelinef("250-STARTTLS") } else if c.extRequireTLS { @@ -914,6 +1119,7 @@ func (c *conn) cmdHello(p *parser, ehlo bool) { c.bwritelinef("250-REQUIRETLS") } if c.submission { + var mechs string // ../rfc/4954:123 if c.tls || !c.requireTLSForAuth { // We always mention the SCRAM PLUS variants, even if TLS is not active: It is a @@ -921,10 +1127,12 @@ func (c *conn) cmdHello(p *parser, ehlo bool) { // authentication. The client should select the bare variant when TLS isn't // present, and also not indicate the server supports the PLUS variant in that // case, or it would trigger the mechanism downgrade detection. - c.bwritelinef("250-AUTH SCRAM-SHA-256-PLUS SCRAM-SHA-256 SCRAM-SHA-1-PLUS SCRAM-SHA-1 CRAM-MD5 PLAIN LOGIN") - } else { - c.bwritelinef("250-AUTH ") + mechs = "SCRAM-SHA-256-PLUS SCRAM-SHA-256 SCRAM-SHA-1-PLUS SCRAM-SHA-1 CRAM-MD5 PLAIN LOGIN" } + if c.tls && len(c.conn.(*tls.Conn).ConnectionState().PeerCertificates) > 0 { + mechs = "EXTERNAL " + mechs + } + c.bwritelinef("250-AUTH %s", mechs) // ../rfc/4865:127 t := time.Now().Add(queue.FutureReleaseIntervalMax).UTC() // ../rfc/4865:98 c.bwritelinef("250-FUTURERELEASE %d %s", queue.FutureReleaseIntervalMax/time.Second, t.Format(time.RFC3339)) @@ -949,7 +1157,7 @@ func (c *conn) cmdStarttls(p *parser) { if c.account != nil { xsmtpUserErrorf(smtp.C503BadCmdSeq, smtp.SeProto5BadCmdOrSeq1, "cannot starttls after authentication") } - if c.tlsConfig == nil { + if c.baseTLSConfig == nil { xsmtpUserErrorf(smtp.C503BadCmdSeq, smtp.SeProto5BadCmdOrSeq1, "starttls not offered") } @@ -967,61 +1175,13 @@ func (c *conn) cmdStarttls(p *parser) { // We add the cid to the output, to help debugging in case of a failing TLS connection. c.writecodeline(smtp.C220ServiceReady, smtp.SeOther00, "go! ("+mox.ReceivedID(c.cid)+")", nil) - tlsConn := tls.Server(conn, c.tlsConfig) - cidctx := context.WithValue(mox.Context, mlog.CidKey, c.cid) - ctx, cancel := context.WithTimeout(cidctx, time.Minute) - defer cancel() - c.log.Debug("starting tls server handshake") - metricDeliveryStarttls.Inc() - if err := tlsConn.HandshakeContext(ctx); err != nil { - // Errors from crypto/tls mostly aren't typed. We'll have to look for strings... - reason := "other" - if errors.Is(err, io.EOF) { - reason = "eof" - } else if alert, ok := asTLSAlert(err); ok { - reason = tlsrpt.FormatAlert(alert) - } else { - s := err.Error() - if strings.Contains(s, "tls: client offered only unsupported versions") { - reason = "unsupportedversions" - } else if strings.Contains(s, "tls: first record does not look like a TLS handshake") { - reason = "nottls" - } else if strings.Contains(s, "tls: unsupported SSLv2 handshake received") { - reason = "sslv2" - } - } - metricDeliveryStarttlsErrors.WithLabelValues(reason).Inc() - panic(fmt.Errorf("starttls handshake: %s (%w)", err, errIO)) - } - cancel() - tlsversion, ciphersuite := moxio.TLSInfo(tlsConn) - c.log.Debug("tls server handshake done", slog.String("tls", tlsversion), slog.String("ciphersuite", ciphersuite)) - c.conn = tlsConn - c.tr = moxio.NewTraceReader(c.log, "RC: ", c) - c.tw = moxio.NewTraceWriter(c.log, "LS: ", c) - c.r = bufio.NewReader(c.tr) - c.w = bufio.NewWriter(c.tw) + + c.xtlsHandshakeAndAuthenticate(conn) c.reset() // ../rfc/3207:210 c.tls = true } -func asTLSAlert(err error) (alert uint8, ok bool) { - // If the remote client aborts the connection, it can send an alert indicating why. - // crypto/tls gives us a net.OpError with "Op" set to "remote error", an an Err - // with the unexported type "alert", a uint8. So we try to read it. - - var opErr *net.OpError - if !errors.As(err, &opErr) || opErr.Op != "remote error" || opErr.Err == nil { - return - } - v := reflect.ValueOf(opErr.Err) - if v.Kind() != reflect.Uint8 || v.Type().Name() != "alert" { - return - } - return uint8(v.Uint()), true -} - // ../rfc/4954:139 func (c *conn) cmdAuth(p *parser) { c.xneedHello() @@ -1029,7 +1189,7 @@ func (c *conn) cmdAuth(p *parser) { if !c.submission { xsmtpUserErrorf(smtp.C503BadCmdSeq, smtp.SeProto5BadCmdOrSeq1, "authentication only allowed on submission ports") } - if c.account != nil { + if c.authSASL { // ../rfc/4954:152 xsmtpUserErrorf(smtp.C503BadCmdSeq, smtp.SeProto5BadCmdOrSeq1, "already authenticated") } @@ -1062,7 +1222,7 @@ func (c *conn) cmdAuth(p *parser) { } }() - var authVariant string + var authVariant string // Only known strings, used in metrics. authResult := "error" defer func() { metrics.AuthenticationInc("submission", authVariant, authResult) @@ -1129,6 +1289,18 @@ func (c *conn) cmdAuth(p *parser) { return buf } + // The various authentication mechanisms set account and username. We may already + // have an account and username from TLS client authentication. Afterwards, we + // check that the account is the same. + var account *store.Account + var username string + defer func() { + if account != nil { + err := account.Close() + c.log.Check(err, "close account") + } + }() + switch mech { case "PLAIN": authVariant = "plain" @@ -1148,31 +1320,24 @@ func (c *conn) cmdAuth(p *parser) { xsmtpUserErrorf(smtp.C501BadParamSyntax, smtp.SeProto5BadParams4, "auth data should have 3 nul-separated tokens, got %d", len(plain)) } authz := norm.NFC.String(string(plain[0])) - authc := norm.NFC.String(string(plain[1])) + username = norm.NFC.String(string(plain[1])) password := string(plain[2]) - if authz != "" && authz != authc { + if authz != "" && authz != username { authResult = "badcreds" xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "cannot assume other role") } - acc, err := store.OpenEmailAuth(c.log, authc, password) + var err error + account, err = store.OpenEmailAuth(c.log, username, password) if err != nil && errors.Is(err, store.ErrUnknownCredentials) { // ../rfc/4954:274 authResult = "badcreds" - c.log.Info("failed authentication attempt", slog.String("username", authc), slog.Any("remote", c.remoteIP)) + c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP)) xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "bad user/pass") } xcheckf(err, "verifying credentials") - authResult = "ok" - c.authFailed = 0 - c.setSlow(false) - c.account = acc - c.username = authc - // ../rfc/4954:276 - c.writecodeline(smtp.C235AuthSuccess, smtp.SePol7Other0, "nice", nil) - case "LOGIN": // LOGIN is obsoleted in favor of PLAIN, only implemented to support legacy // clients, see Internet-Draft (I-D): @@ -1193,7 +1358,7 @@ func (c *conn) cmdAuth(p *parser) { // I-D says maximum length must be 64 bytes. We allow more, for long user names // (domains). encChal := base64.StdEncoding.EncodeToString([]byte("Username:")) - username := string(xreadInitial(encChal)) + username = string(xreadInitial(encChal)) username = norm.NFC.String(username) // Again, client should ignore the challenge, we send the same as the example in @@ -1205,7 +1370,8 @@ func (c *conn) cmdAuth(p *parser) { password := string(xreadContinuation()) c.xtrace(mlog.LevelTrace) // Restore. - acc, err := store.OpenEmailAuth(c.log, username, password) + var err error + account, err = store.OpenEmailAuth(c.log, username, password) if err != nil && errors.Is(err, store.ErrUnknownCredentials) { // ../rfc/4954:274 authResult = "badcreds" @@ -1214,14 +1380,6 @@ func (c *conn) cmdAuth(p *parser) { } xcheckf(err, "verifying credentials") - authResult = "ok" - c.authFailed = 0 - c.setSlow(false) - c.account = acc - c.username = username - // ../rfc/4954:276 - c.writecodeline(smtp.C235AuthSuccess, smtp.SePol7Other0, "hello ancient smtp implementation", nil) - case "CRAM-MD5": authVariant = strings.ToLower(mech) @@ -1236,26 +1394,21 @@ func (c *conn) cmdAuth(p *parser) { if len(t) != 2 || len(t[1]) != 2*md5.Size { xsmtpUserErrorf(smtp.C501BadParamSyntax, smtp.SeProto5BadParams4, "malformed cram-md5 response") } - addr := norm.NFC.String(t[0]) - c.log.Debug("cram-md5 auth", slog.String("address", addr)) - acc, _, err := store.OpenEmail(c.log, addr) + username = norm.NFC.String(t[0]) + c.log.Debug("cram-md5 auth", slog.String("username", username)) + var err error + account, _, err = store.OpenEmail(c.log, username) if err != nil && errors.Is(err, store.ErrUnknownCredentials) { - c.log.Info("failed authentication attempt", slog.String("username", addr), slog.Any("remote", c.remoteIP)) + c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP)) xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "bad user/pass") } xcheckf(err, "looking up address") - defer func() { - if acc != nil { - err := acc.Close() - c.log.Check(err, "closing account") - } - }() var ipadhash, opadhash hash.Hash - acc.WithRLock(func() { - err := acc.DB.Read(context.TODO(), func(tx *bstore.Tx) error { + account.WithRLock(func() { + err := account.DB.Read(context.TODO(), func(tx *bstore.Tx) error { password, err := bstore.QueryTx[store.Password](tx).Get() if err == bstore.ErrAbsent { - c.log.Info("failed authentication attempt", slog.String("username", addr), slog.Any("remote", c.remoteIP)) + c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP)) xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "bad user/pass") } if err != nil { @@ -1270,8 +1423,8 @@ func (c *conn) cmdAuth(p *parser) { }) if ipadhash == nil || opadhash == nil { missingDerivedSecrets = true - c.log.Info("cram-md5 auth attempt without derived secrets set, save password again to store secrets", slog.String("username", addr)) - c.log.Info("failed authentication attempt", slog.String("username", addr), slog.Any("remote", c.remoteIP)) + c.log.Info("cram-md5 auth attempt without derived secrets set, save password again to store secrets", slog.String("username", username)) + c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP)) xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "bad user/pass") } @@ -1280,19 +1433,10 @@ func (c *conn) cmdAuth(p *parser) { opadhash.Write(ipadhash.Sum(nil)) digest := fmt.Sprintf("%x", opadhash.Sum(nil)) if digest != t[1] { - c.log.Info("failed authentication attempt", slog.String("username", addr), slog.Any("remote", c.remoteIP)) + c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP)) xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "bad user/pass") } - authResult = "ok" - c.authFailed = 0 - c.setSlow(false) - c.account = acc - acc = nil // Cancel cleanup. - c.username = addr - // ../rfc/4954:276 - c.writecodeline(smtp.C235AuthSuccess, smtp.SePol7Other0, "nice", nil) - case "SCRAM-SHA-256-PLUS", "SCRAM-SHA-256", "SCRAM-SHA-1-PLUS", "SCRAM-SHA-1": // todo: improve handling of errors during scram. e.g. invalid parameters. should we abort the imap command, or continue until the end and respond with a scram-level error? // todo: use single implementation between ../imapserver/server.go and ../smtpserver/server.go @@ -1326,31 +1470,25 @@ func (c *conn) cmdAuth(p *parser) { c.log.Infox("scram protocol error", err, slog.Any("remote", c.remoteIP)) xsmtpUserErrorf(smtp.C455BadParams, smtp.SePol7Other0, "scram protocol error: %s", err) } - authc := norm.NFC.String(ss.Authentication) - c.log.Debug("scram auth", slog.String("authentication", authc)) - acc, _, err := store.OpenEmail(c.log, authc) + username = norm.NFC.String(ss.Authentication) + c.log.Debug("scram auth", slog.String("authentication", username)) + account, _, err = store.OpenEmail(c.log, username) if err != nil { // todo: we could continue scram with a generated salt, deterministically generated // from the username. that way we don't have to store anything but attackers cannot // learn if an account exists. same for absent scram saltedpassword below. - c.log.Info("failed authentication attempt", slog.String("username", authc), slog.Any("remote", c.remoteIP)) + c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP)) xsmtpUserErrorf(smtp.C454TempAuthFail, smtp.SeSys3Other0, "scram not possible") } - defer func() { - if acc != nil { - err := acc.Close() - c.log.Check(err, "closing account") - } - }() - if ss.Authorization != "" && ss.Authorization != ss.Authentication { + if ss.Authorization != "" && ss.Authorization != username { xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "authentication with authorization for different user not supported") } var xscram store.SCRAM - acc.WithRLock(func() { - err := acc.DB.Read(context.TODO(), func(tx *bstore.Tx) error { + account.WithRLock(func() { + err := account.DB.Read(context.TODO(), func(tx *bstore.Tx) error { password, err := bstore.QueryTx[store.Password](tx).Get() if err == bstore.ErrAbsent { - c.log.Info("failed authentication attempt", slog.String("username", authc), slog.Any("remote", c.remoteIP)) + c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP)) xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "bad user/pass") } xcheckf(err, "fetching credentials") @@ -1364,8 +1502,8 @@ func (c *conn) cmdAuth(p *parser) { } if len(xscram.Salt) == 0 || xscram.Iterations == 0 || len(xscram.SaltedPassword) == 0 { missingDerivedSecrets = true - c.log.Info("scram auth attempt without derived secrets set, save password again to store secrets", slog.String("address", authc)) - c.log.Info("failed authentication attempt", slog.String("username", authc), slog.Any("remote", c.remoteIP)) + c.log.Info("scram auth attempt without derived secrets set, save password again to store secrets", slog.String("address", username)) + c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP)) xsmtpUserErrorf(smtp.C454TempAuthFail, smtp.SeSys3Other0, "scram not possible") } return nil @@ -1384,14 +1522,14 @@ func (c *conn) cmdAuth(p *parser) { c.readline() // Should be "*" for cancellation. if errors.Is(err, scram.ErrInvalidProof) { authResult = "badcreds" - c.log.Info("failed authentication attempt", slog.String("username", authc), slog.Any("remote", c.remoteIP)) + c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP)) xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "bad credentials") } else if errors.Is(err, scram.ErrChannelBindingsDontMatch) { authResult = "badchanbind" - c.log.Warn("bad channel binding during authentication, potential mitm", slog.String("username", authc), slog.Any("remote", c.remoteIP)) + c.log.Warn("bad channel binding during authentication, potential mitm", slog.String("username", username), slog.Any("remote", c.remoteIP)) xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7MsgIntegrity7, "channel bindings do not match, potential mitm") } else if errors.Is(err, scram.ErrInvalidEncoding) { - c.log.Infox("bad scram protocol message", err, slog.String("username", authc), slog.Any("remote", c.remoteIP)) + c.log.Infox("bad scram protocol message", err, slog.String("username", username), slog.Any("remote", c.remoteIP)) xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7Other0, "bad scram protocol message") } xcheckf(err, "server final") @@ -1401,19 +1539,65 @@ func (c *conn) cmdAuth(p *parser) { // The message should be empty. todo: should we require it is empty? xreadContinuation() - authResult = "ok" - c.authFailed = 0 - c.setSlow(false) - c.account = acc - acc = nil // Cancel cleanup. - c.username = authc - // ../rfc/4954:276 - c.writecodeline(smtp.C235AuthSuccess, smtp.SePol7Other0, "nice", nil) + case "EXTERNAL": + authVariant = strings.ToLower(mech) + + // ../rfc/4422:1618 + buf := xreadInitial("") + username = string(buf) + + if !c.tls { + // ../rfc/4954:630 + xsmtpUserErrorf(smtp.C538EncReqForAuth, smtp.SePol7EncReqForAuth11, "tls required for tls client certificate authentication") + } + if c.account == nil { + xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "missing client certificate, required for tls client certificate authentication") + } + + if username == "" { + username = c.username + } + var err error + account, _, err = store.OpenEmail(c.log, username) + xcheckf(err, "looking up username from tls client authentication") default: // ../rfc/4954:176 xsmtpUserErrorf(smtp.C504ParamNotImpl, smtp.SeProto5BadParams4, "mechanism %s not supported", mech) } + + // We may already have TLS credentials. We allow an additional SASL authentication, + // possibly with different username, but the account must be the same. + if c.account != nil { + if account != c.account { + c.log.Debug("sasl authentication for different account than tls client authentication, aborting connection", + slog.String("saslmechanism", authVariant), + slog.String("saslaccount", account.Name), + slog.String("tlsaccount", c.account.Name), + slog.String("saslusername", username), + slog.String("tlsusername", c.username), + ) + xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "authentication failed, tls client certificate public key belongs to another account") + } else if username != c.username { + c.log.Debug("sasl authentication for different username than tls client certificate authentication, switching to sasl username", + slog.String("saslmechanism", authVariant), + slog.String("saslusername", username), + slog.String("tlsusername", c.username), + slog.String("account", c.account.Name), + ) + } + } else { + c.account = account + account = nil // Prevent cleanup. + } + c.username = username + + authResult = "ok" + c.authSASL = true + c.authFailed = 0 + c.setSlow(false) + // ../rfc/4954:276 + c.writecodeline(smtp.C235AuthSuccess, smtp.SePol7Other0, "nice", nil) } // ../rfc/5321:1879 ../rfc/5321:1025 diff --git a/smtpserver/server_test.go b/smtpserver/server_test.go index 70b12be0f..d05814295 100644 --- a/smtpserver/server_test.go +++ b/smtpserver/server_test.go @@ -82,19 +82,23 @@ test email, unique. `, "\n", "\r\n") type testserver struct { - t *testing.T - acc *store.Account - switchStop func() - comm *store.Comm - cid int64 - resolver dns.Resolver - auth func(mechanisms []string, cs *tls.ConnectionState) (sasl.Client, error) - user, pass string - submission bool - requiretls bool - dnsbls []dns.Domain - tlsmode smtpclient.TLSMode - tlspkix bool + t *testing.T + acc *store.Account + switchStop func() + comm *store.Comm + cid int64 + resolver dns.Resolver + auth func(mechanisms []string, cs *tls.ConnectionState) (sasl.Client, error) + user, pass string + immediateTLS bool + serverConfig *tls.Config + clientConfig *tls.Config + clientCert *tls.Certificate // Passed to smtpclient for starttls authentication. + submission bool + requiretls bool + dnsbls []dns.Domain + tlsmode smtpclient.TLSMode + tlspkix bool } const password0 = "te\u0301st \u00a0\u2002\u200a" // NFD and various unicode spaces. @@ -103,9 +107,23 @@ const password1 = "tést " // PRECIS normalized, with NF func newTestServer(t *testing.T, configPath string, resolver dns.Resolver) *testserver { limitersInit() // Reset rate limiters. - ts := testserver{t: t, cid: 1, resolver: resolver, tlsmode: smtpclient.TLSOpportunistic} - log := mlog.New("smtpserver", nil) + + ts := testserver{ + t: t, + cid: 1, + resolver: resolver, + tlsmode: smtpclient.TLSOpportunistic, + serverConfig: &tls.Config{ + Certificates: []tls.Certificate{fakeCert(t, false)}, + }, + } + + // Ensure session keys, for tests that check resume and authentication. + ctx, cancel := context.WithCancel(ctxbg) + defer cancel() + mox.StartTLSSessionTicketKeyRefresher(ctx, log, ts.serverConfig) + mox.Context = ctxbg mox.ConfigStaticPath = configPath mox.MustLoadConfig(true, false) @@ -116,6 +134,8 @@ func newTestServer(t *testing.T, configPath string, resolver dns.Resolver) *test tcheck(t, err, "dmarcdb init") err = tlsrptdb.Init() tcheck(t, err, "tlsrptdb init") + err = store.Init(ctxbg) + tcheck(t, err, "store init") ts.acc, err = store.OpenAccount(log, "mjl") tcheck(t, err, "open account") @@ -139,6 +159,8 @@ func (ts *testserver) close() { tcheck(ts.t, err, "dmarcdb close") err = tlsrptdb.Close() tcheck(ts.t, err, "tlsrptdb close") + err = store.Close() + tcheck(ts.t, err, "store close") ts.comm.Unregister() queue.Shutdown() ts.switchStop() @@ -180,8 +202,9 @@ func (ts *testserver) run(fn func(helloErr error, client *smtpclient.Client)) { ourHostname := mox.Conf.Static.HostnameDomain remoteHostname := dns.Domain{ASCII: "mox.example"} opts := smtpclient.Opts{ - Auth: auth, - RootCAs: mox.Conf.Static.TLS.CertPool, + Auth: auth, + RootCAs: mox.Conf.Static.TLS.CertPool, + ClientCert: ts.clientCert, } log := pkglog.WithCid(ts.cid - 1) client, err := smtpclient.New(ctxbg, log.Logger, conn, ts.tlsmode, ts.tlspkix, ourHostname, remoteHostname, opts) @@ -206,13 +229,14 @@ func (ts *testserver) runRaw(fn func(clientConn net.Conn)) { defer func() { <-serverdone }() go func() { - tlsConfig := &tls.Config{ - Certificates: []tls.Certificate{fakeCert(ts.t)}, - } - serve("test", ts.cid-2, dns.Domain{ASCII: "mox.example"}, tlsConfig, serverConn, ts.resolver, ts.submission, false, 100<<20, false, false, ts.requiretls, ts.dnsbls, 0) + serve("test", ts.cid-2, dns.Domain{ASCII: "mox.example"}, ts.serverConfig, serverConn, ts.resolver, ts.submission, ts.immediateTLS, 100<<20, false, false, ts.requiretls, ts.dnsbls, 0) close(serverdone) }() + if ts.immediateTLS { + clientConn = tls.Client(clientConn, ts.clientConfig) + } + fn(clientConn) } @@ -228,10 +252,17 @@ func (ts *testserver) smtpErr(err error, expErr *smtpclient.Error) { // Just a cert that appears valid. SMTP client will not verify anything about it // (that is opportunistic TLS for you, "better some than none"). Let's enjoy this // one moment where it makes life easier. -func fakeCert(t *testing.T) tls.Certificate { - privKey := ed25519.NewKeyFromSeed(make([]byte, ed25519.SeedSize)) // Fake key, don't use this for real! +func fakeCert(t *testing.T, randomkey bool) tls.Certificate { + seed := make([]byte, ed25519.SeedSize) + if randomkey { + cryptorand.Read(seed) + } + privKey := ed25519.NewKeyFromSeed(seed) // Fake key, don't use this for real! template := &x509.Certificate{ SerialNumber: big.NewInt(1), // Required field... + // Valid period is needed to get session resumption enabled. + NotBefore: time.Now().Add(-time.Minute), + NotAfter: time.Now().Add(time.Hour), } localCertBuf, err := x509.CreateCertificate(cryptorand.Reader, template, template, privKey.Public(), privKey) if err != nil { @@ -330,6 +361,108 @@ func TestSubmission(t *testing.T) { testAuth(fn, "mo\u0301x@mox.example", password0, nil) testAuth(fn, "mo\u0301x@mox.example", password1, nil) } + + // Create a certificate, register its public key with account, and make a tls + // client config that sends the certificate. + clientCert0 := fakeCert(ts.t, true) + tlspubkey, err := store.ParseTLSPublicKeyCert(clientCert0.Certificate[0]) + tcheck(t, err, "parse certificate") + tlspubkey.Account = "mjl" + tlspubkey.LoginAddress = "mjl@mox.example" + err = store.TLSPublicKeyAdd(ctxbg, &tlspubkey) + tcheck(t, err, "add tls public key to account") + ts.immediateTLS = true + ts.clientConfig = &tls.Config{ + InsecureSkipVerify: true, + Certificates: []tls.Certificate{ + clientCert0, + }, + } + + // No explicit address in EXTERNAL. + testAuth(func(user, pass string, cs *tls.ConnectionState) sasl.Client { + return sasl.NewClientExternal(user) + }, "", "", nil) + + // Same username in EXTERNAL as configured for key. + testAuth(func(user, pass string, cs *tls.ConnectionState) sasl.Client { + return sasl.NewClientExternal(user) + }, "mjl@mox.example", "", nil) + + // Different username in EXTERNAL as configured for key, but same account. + testAuth(func(user, pass string, cs *tls.ConnectionState) sasl.Client { + return sasl.NewClientExternal(user) + }, "móx@mox.example", "", nil) + + // Different username as configured for key, but same account, but not EXTERNAL auth. + testAuth(func(user, pass string, cs *tls.ConnectionState) sasl.Client { + return sasl.NewClientSCRAMSHA256PLUS(user, pass, *cs) + }, "móx@mox.example", password0, nil) + + // Different account results in error. + testAuth(func(user, pass string, cs *tls.ConnectionState) sasl.Client { + return sasl.NewClientExternal(user) + }, "☺@mox.example", "", &smtpclient.Error{Code: smtp.C535AuthBadCreds, Secode: smtp.SePol7AuthBadCreds8}) + + // Starttls with client cert should authenticate too. + ts.immediateTLS = false + ts.clientCert = &clientCert0 + testAuth(func(user, pass string, cs *tls.ConnectionState) sasl.Client { + return sasl.NewClientExternal(user) + }, "", "", nil) + ts.immediateTLS = true + ts.clientCert = nil + + // Add a client session cache, so our connections will be resumed. We are testing + // that the credentials are applied to resumed connections too. + ts.clientConfig.ClientSessionCache = tls.NewLRUClientSessionCache(10) + testAuth(func(user, pass string, cs *tls.ConnectionState) sasl.Client { + if cs.DidResume { + panic("tls connection was resumed") + } + return sasl.NewClientExternal(user) + }, "", "", nil) + testAuth(func(user, pass string, cs *tls.ConnectionState) sasl.Client { + if !cs.DidResume { + panic("tls connection was not resumed") + } + return sasl.NewClientExternal(user) + }, "", "", nil) + + // Unknown client certificate should fail the connection. + serverConn, clientConn := net.Pipe() + serverdone := make(chan struct{}) + defer func() { <-serverdone }() + + go func() { + defer serverConn.Close() + tlsConfig := &tls.Config{ + Certificates: []tls.Certificate{fakeCert(ts.t, false)}, + } + serve("test", ts.cid-2, dns.Domain{ASCII: "mox.example"}, tlsConfig, serverConn, ts.resolver, ts.submission, ts.immediateTLS, 100<<20, false, false, false, ts.dnsbls, 0) + close(serverdone) + }() + + defer clientConn.Close() + + // Authentication with an unknown/untrusted certificate should fail. + clientCert1 := fakeCert(ts.t, true) + ts.clientConfig.ClientSessionCache = nil + ts.clientConfig.Certificates = []tls.Certificate{ + clientCert1, + } + clientConn = tls.Client(clientConn, ts.clientConfig) + // note: It's not enough to do a handshake and check if that was successful. If the + // client cert is not acceptable, we only learn after the handshake, when the first + // data messages are exchanged. + buf := make([]byte, 100) + _, err = clientConn.Read(buf) + if err == nil { + t.Fatalf("tls handshake with unknown client certificate succeeded") + } + if alert, ok := mox.AsTLSAlert(err); !ok || alert != 42 { + t.Fatalf("got err %#v, expected tls 'bad certificate' alert", err) + } } // Test delivery from external MTA. @@ -1247,7 +1380,7 @@ func TestNonSMTP(t *testing.T) { go func() { tlsConfig := &tls.Config{ - Certificates: []tls.Certificate{fakeCert(ts.t)}, + Certificates: []tls.Certificate{fakeCert(ts.t, false)}, } serve("test", ts.cid-2, dns.Domain{ASCII: "mox.example"}, tlsConfig, serverConn, ts.resolver, ts.submission, false, 100<<20, false, false, false, ts.dnsbls, 0) close(serverdone) diff --git a/store/tlspubkey.go b/store/tlspubkey.go new file mode 100644 index 000000000..3d6618323 --- /dev/null +++ b/store/tlspubkey.go @@ -0,0 +1,168 @@ +package store + +import ( + "context" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/mjl-/bstore" + + "github.com/mjl-/mox/mlog" + "github.com/mjl-/mox/mox-" + "github.com/mjl-/mox/smtp" +) + +// TLSPublicKey is a public key for use with TLS client authentication based on the +// public key of the certificate. +type TLSPublicKey struct { + // Raw-url-base64-encoded Subject Public Key Info of certificate. + Fingerprint string + Created time.Time `bstore:"nonzero,default now"` + Type string // E.g. "rsa-2048", "ecdsa-p256", "ed25519" + + // Descriptive name to identify the key, e.g. the device where key is used. + Name string `bstore:"nonzero"` + + // If set, new immediate authenticated TLS connections are not moved to + // "authenticated" state. For clients that don't understand it, and will try an + // authenticate command anyway. + NoIMAPPreauth bool + + CertDER []byte `bstore:"nonzero"` + Account string `bstore:"nonzero"` // Key authenticates this account. + LoginAddress string `bstore:"nonzero"` // Must belong to account. +} + +// AuthDB and AuthDBTypes are exported for ../backup.go. +var AuthDB *bstore.DB +var AuthDBTypes = []any{TLSPublicKey{}} + +// Init opens auth.db. +func Init(ctx context.Context) error { + if AuthDB != nil { + return fmt.Errorf("already initialized") + } + pkglog := mlog.New("store", nil) + p := mox.DataDirPath("auth.db") + os.MkdirAll(filepath.Dir(p), 0770) + opts := bstore.Options{Timeout: 5 * time.Second, Perm: 0660, RegisterLogger: pkglog.Logger} + var err error + AuthDB, err = bstore.Open(ctx, p, &opts, AuthDBTypes...) + return err +} + +// Close closes auth.db. +func Close() error { + if AuthDB == nil { + return fmt.Errorf("not open") + } + err := AuthDB.Close() + AuthDB = nil + return err +} + +// ParseTLSPublicKeyCert parses a certificate, preparing a TLSPublicKey for +// insertion into the database. Caller must set fields that are not in the +// certificat, such as Account and LoginAddress. +func ParseTLSPublicKeyCert(certDER []byte) (TLSPublicKey, error) { + cert, err := x509.ParseCertificate(certDER) + if err != nil { + return TLSPublicKey{}, fmt.Errorf("parsing certificate: %v", err) + } + name := cert.Subject.CommonName + if name == "" && cert.SerialNumber != nil { + name = fmt.Sprintf("serial %x", cert.SerialNumber.Bytes()) + } + + buf := sha256.Sum256(cert.RawSubjectPublicKeyInfo) + fp := base64.RawURLEncoding.EncodeToString(buf[:]) + var typ string + switch k := cert.PublicKey.(type) { + case *rsa.PublicKey: + bits := k.N.BitLen() + if bits < 2048 { + return TLSPublicKey{}, fmt.Errorf("rsa keys smaller than 2048 bits not accepted") + } + typ = "rsa-" + fmt.Sprintf("%d", bits) + case *ecdsa.PublicKey: + typ = "ecdsa-" + strings.ReplaceAll(strings.ToLower(k.Params().Name), "-", "") + case ed25519.PublicKey: + typ = "ed25519" + default: + return TLSPublicKey{}, fmt.Errorf("public key type %T not implemented", cert.PublicKey) + } + + return TLSPublicKey{Fingerprint: fp, Type: typ, Name: name, CertDER: certDER}, nil +} + +// TLSPublicKeyList returns tls public keys. If accountOpt is empty, keys for all +// accounts are returned. +func TLSPublicKeyList(ctx context.Context, accountOpt string) ([]TLSPublicKey, error) { + q := bstore.QueryDB[TLSPublicKey](ctx, AuthDB) + if accountOpt != "" { + q.FilterNonzero(TLSPublicKey{Account: accountOpt}) + } + return q.List() +} + +// TLSPublicKeyGet retrieves a single tls public key by fingerprint. +// If absent, bstore.ErrAbsent is returned. +func TLSPublicKeyGet(ctx context.Context, fingerprint string) (TLSPublicKey, error) { + pubKey := TLSPublicKey{Fingerprint: fingerprint} + err := AuthDB.Get(ctx, &pubKey) + return pubKey, err +} + +// TLSPublicKeyAdd adds a new tls public key. +// +// Caller is responsible for checking the account and email address are valid. +func TLSPublicKeyAdd(ctx context.Context, pubKey *TLSPublicKey) error { + if err := checkTLSPublicKeyAddress(pubKey.LoginAddress); err != nil { + return err + } + return AuthDB.Insert(ctx, pubKey) +} + +// TLSPublicKeyUpdate updates an existing tls public key. +// +// Caller is responsible for checking the account and email address are valid. +func TLSPublicKeyUpdate(ctx context.Context, pubKey *TLSPublicKey) error { + if err := checkTLSPublicKeyAddress(pubKey.LoginAddress); err != nil { + return err + } + return AuthDB.Update(ctx, pubKey) +} + +func checkTLSPublicKeyAddress(addr string) error { + a, err := smtp.ParseAddress(addr) + if err != nil { + return fmt.Errorf("parsing login address %q: %v", addr, err) + } + if a.String() != addr { + return fmt.Errorf("login address %q must be specified in canonical form %q", addr, a.String()) + } + return nil +} + +// TLSPublicKeyRemove removes a tls public key. +func TLSPublicKeyRemove(ctx context.Context, fingerprint string) error { + k := TLSPublicKey{Fingerprint: fingerprint} + return AuthDB.Delete(ctx, &k) +} + +// TLSPublicKeyRemoveForAccount removes all tls public keys for an account. +func TLSPublicKeyRemoveForAccount(ctx context.Context, account string) error { + q := bstore.QueryDB[TLSPublicKey](ctx, AuthDB) + q.FilterNonzero(TLSPublicKey{Account: account}) + _, err := q.Delete() + return err +} diff --git a/testdata/imap/domains.conf b/testdata/imap/domains.conf index 320ded3d9..b04545d82 100644 --- a/testdata/imap/domains.conf +++ b/testdata/imap/domains.conf @@ -15,6 +15,10 @@ Accounts: MaxPower: 0.1 TopWords: 10 IgnoreWords: 0.1 + other: + Domain: mox.example + Destinations: + other@mox.example: nil limit: Domain: mox.example Destinations: diff --git a/verifydata.go b/verifydata.go index 9d01e447e..9d9081d61 100644 --- a/verifydata.go +++ b/verifydata.go @@ -422,7 +422,7 @@ possibly making them potentially no longer readable by the previous version. p = p[len(dataDir)+1:] } switch p { - case "dmarcrpt.db", "dmarceval.db", "mtasts.db", "tlsrpt.db", "tlsrptresult.db", "receivedid.key", "lastknownversion": + case "auth.db", "dmarcrpt.db", "dmarceval.db", "mtasts.db", "tlsrpt.db", "tlsrptresult.db", "receivedid.key", "lastknownversion": return nil case "acme", "queue", "accounts", "tmp", "moved": return fs.SkipDir @@ -440,6 +440,7 @@ possibly making them potentially no longer readable by the previous version. checkf(err, dataDir, "walking data directory") } + checkDB(false, filepath.Join(dataDir, "auth.db"), store.AuthDBTypes) // Since v0.0.14. checkDB(true, filepath.Join(dataDir, "dmarcrpt.db"), dmarcdb.ReportsDBTypes) checkDB(false, filepath.Join(dataDir, "dmarceval.db"), dmarcdb.EvalDBTypes) // After v0.0.7. checkDB(true, filepath.Join(dataDir, "mtasts.db"), mtastsdb.DBTypes) diff --git a/webaccount/account.go b/webaccount/account.go index cc0d74b63..c8e7a07f7 100644 --- a/webaccount/account.go +++ b/webaccount/account.go @@ -8,6 +8,7 @@ import ( cryptorand "crypto/rand" "encoding/base64" "encoding/json" + "encoding/pem" "errors" "fmt" "io" @@ -671,3 +672,80 @@ func (Account) RejectsSave(ctx context.Context, mailbox string, keep bool) { }) xcheckf(ctx, err, "saving account rejects settings") } + +func (Account) TLSPublicKeys(ctx context.Context) ([]store.TLSPublicKey, error) { + reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo) + return store.TLSPublicKeyList(ctx, reqInfo.AccountName) +} + +func (Account) TLSPublicKeyAdd(ctx context.Context, loginAddress, name string, noIMAPPreauth bool, certPEM string) (store.TLSPublicKey, error) { + reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo) + + block, rest := pem.Decode([]byte(certPEM)) + var err error + if block == nil { + err = errors.New("no pem data found") + } else if block.Type != "CERTIFICATE" { + err = fmt.Errorf("unexpected type %q, need CERTIFICATE", block.Type) + } else if len(rest) != 0 { + err = errors.New("only single pem block allowed") + } + xcheckuserf(ctx, err, "parsing pem file") + + tpk, err := store.ParseTLSPublicKeyCert(block.Bytes) + xcheckuserf(ctx, err, "parsing certificate") + if name != "" { + tpk.Name = name + } + tpk.Account = reqInfo.AccountName + tpk.LoginAddress = loginAddress + tpk.NoIMAPPreauth = noIMAPPreauth + err = store.TLSPublicKeyAdd(ctx, &tpk) + if err != nil && errors.Is(err, bstore.ErrUnique) { + xcheckuserf(ctx, err, "add tls public key") + } else { + xcheckf(ctx, err, "add tls public key") + } + return tpk, nil +} + +func xtlspublickey(ctx context.Context, account string, fingerprint string) store.TLSPublicKey { + tpk, err := store.TLSPublicKeyGet(ctx, fingerprint) + if err == nil && tpk.Account != account { + err = bstore.ErrAbsent + } + if err == bstore.ErrAbsent { + xcheckuserf(ctx, err, "get tls public key") + } + xcheckf(ctx, err, "get tls public key") + return tpk +} + +func (Account) TLSPublicKeyRemove(ctx context.Context, fingerprint string) error { + reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo) + xtlspublickey(ctx, reqInfo.AccountName, fingerprint) + return store.TLSPublicKeyRemove(ctx, fingerprint) +} + +func (Account) TLSPublicKeyUpdate(ctx context.Context, pubKey store.TLSPublicKey) error { + reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo) + tpk := xtlspublickey(ctx, reqInfo.AccountName, pubKey.Fingerprint) + log := pkglog.WithContext(ctx) + acc, _, err := store.OpenEmail(log, pubKey.LoginAddress) + if err == nil && acc.Name != reqInfo.AccountName { + err = store.ErrUnknownCredentials + } + if acc != nil { + xerr := acc.Close() + log.Check(xerr, "close account") + } + if err == store.ErrUnknownCredentials { + xcheckuserf(ctx, errors.New("unknown address"), "looking up address") + } + tpk.Name = pubKey.Name + tpk.LoginAddress = pubKey.LoginAddress + tpk.NoIMAPPreauth = pubKey.NoIMAPPreauth + err = store.TLSPublicKeyUpdate(ctx, &tpk) + xcheckf(ctx, err, "updating tls public key") + return nil +} diff --git a/webaccount/account.js b/webaccount/account.js index 526dc6bbe..c68952ceb 100644 --- a/webaccount/account.js +++ b/webaccount/account.js @@ -255,7 +255,7 @@ var api; // per-outgoing-message address used for sending. OutgoingEvent["EventUnrecognized"] = "unrecognized"; })(OutgoingEvent = api.OutgoingEvent || (api.OutgoingEvent = {})); - api.structTypes = { "Account": true, "Address": true, "AddressAlias": true, "Alias": true, "AliasAddress": true, "AutomaticJunkFlags": true, "Destination": true, "Domain": true, "ImportProgress": true, "Incoming": true, "IncomingMeta": true, "IncomingWebhook": true, "JunkFilter": true, "NameAddress": true, "Outgoing": true, "OutgoingWebhook": true, "Route": true, "Ruleset": true, "Structure": true, "SubjectPass": true, "Suppression": true }; + api.structTypes = { "Account": true, "Address": true, "AddressAlias": true, "Alias": true, "AliasAddress": true, "AutomaticJunkFlags": true, "Destination": true, "Domain": true, "ImportProgress": true, "Incoming": true, "IncomingMeta": true, "IncomingWebhook": true, "JunkFilter": true, "NameAddress": true, "Outgoing": true, "OutgoingWebhook": true, "Route": true, "Ruleset": true, "Structure": true, "SubjectPass": true, "Suppression": true, "TLSPublicKey": true }; api.stringsTypes = { "CSRFToken": true, "Localpart": true, "OutgoingEvent": true }; api.intsTypes = {}; api.types = { @@ -280,6 +280,7 @@ var api; "NameAddress": { "Name": "NameAddress", "Docs": "", "Fields": [{ "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "Address", "Docs": "", "Typewords": ["string"] }] }, "Structure": { "Name": "Structure", "Docs": "", "Fields": [{ "Name": "ContentType", "Docs": "", "Typewords": ["string"] }, { "Name": "ContentTypeParams", "Docs": "", "Typewords": ["{}", "string"] }, { "Name": "ContentID", "Docs": "", "Typewords": ["string"] }, { "Name": "DecodedSize", "Docs": "", "Typewords": ["int64"] }, { "Name": "Parts", "Docs": "", "Typewords": ["[]", "Structure"] }] }, "IncomingMeta": { "Name": "IncomingMeta", "Docs": "", "Fields": [{ "Name": "MsgID", "Docs": "", "Typewords": ["int64"] }, { "Name": "MailFrom", "Docs": "", "Typewords": ["string"] }, { "Name": "MailFromValidated", "Docs": "", "Typewords": ["bool"] }, { "Name": "MsgFromValidated", "Docs": "", "Typewords": ["bool"] }, { "Name": "RcptTo", "Docs": "", "Typewords": ["string"] }, { "Name": "DKIMVerifiedDomains", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "RemoteIP", "Docs": "", "Typewords": ["string"] }, { "Name": "Received", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "MailboxName", "Docs": "", "Typewords": ["string"] }, { "Name": "Automated", "Docs": "", "Typewords": ["bool"] }] }, + "TLSPublicKey": { "Name": "TLSPublicKey", "Docs": "", "Fields": [{ "Name": "Fingerprint", "Docs": "", "Typewords": ["string"] }, { "Name": "Created", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "Type", "Docs": "", "Typewords": ["string"] }, { "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "NoIMAPPreauth", "Docs": "", "Typewords": ["bool"] }, { "Name": "CertDER", "Docs": "", "Typewords": ["nullable", "string"] }, { "Name": "Account", "Docs": "", "Typewords": ["string"] }, { "Name": "LoginAddress", "Docs": "", "Typewords": ["string"] }] }, "CSRFToken": { "Name": "CSRFToken", "Docs": "", "Values": null }, "Localpart": { "Name": "Localpart", "Docs": "", "Values": null }, "OutgoingEvent": { "Name": "OutgoingEvent", "Docs": "", "Values": [{ "Name": "EventDelivered", "Value": "delivered", "Docs": "" }, { "Name": "EventSuppressed", "Value": "suppressed", "Docs": "" }, { "Name": "EventDelayed", "Value": "delayed", "Docs": "" }, { "Name": "EventFailed", "Value": "failed", "Docs": "" }, { "Name": "EventRelayed", "Value": "relayed", "Docs": "" }, { "Name": "EventExpanded", "Value": "expanded", "Docs": "" }, { "Name": "EventCanceled", "Value": "canceled", "Docs": "" }, { "Name": "EventUnrecognized", "Value": "unrecognized", "Docs": "" }] }, @@ -306,6 +307,7 @@ var api; NameAddress: (v) => api.parse("NameAddress", v), Structure: (v) => api.parse("Structure", v), IncomingMeta: (v) => api.parse("IncomingMeta", v), + TLSPublicKey: (v) => api.parse("TLSPublicKey", v), CSRFToken: (v) => api.parse("CSRFToken", v), Localpart: (v) => api.parse("Localpart", v), OutgoingEvent: (v) => api.parse("OutgoingEvent", v), @@ -525,6 +527,34 @@ var api; const params = [mailbox, keep]; return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); } + async TLSPublicKeys() { + const fn = "TLSPublicKeys"; + const paramTypes = []; + const returnTypes = [["[]", "TLSPublicKey"]]; + const params = []; + return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); + } + async TLSPublicKeyAdd(loginAddress, name, noIMAPPreauth, certPEM) { + const fn = "TLSPublicKeyAdd"; + const paramTypes = [["string"], ["string"], ["bool"], ["string"]]; + const returnTypes = [["TLSPublicKey"]]; + const params = [loginAddress, name, noIMAPPreauth, certPEM]; + return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); + } + async TLSPublicKeyRemove(fingerprint) { + const fn = "TLSPublicKeyRemove"; + const paramTypes = [["string"]]; + const returnTypes = []; + const params = [fingerprint]; + return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); + } + async TLSPublicKeyUpdate(pubKey) { + const fn = "TLSPublicKeyUpdate"; + const paramTypes = [["TLSPublicKey"]]; + const returnTypes = []; + const params = [pubKey]; + return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); + } } api.Client = Client; api.defaultBaseURL = (function () { @@ -1092,7 +1122,11 @@ const formatQuotaSize = (v) => { return '' + v; }; const index = async () => { - const [acc, storageUsed, storageLimit, suppressions] = await client.Account(); + const [[acc, storageUsed, storageLimit, suppressions], tlspubkeys0] = await Promise.all([ + client.Account(), + client.TLSPublicKeys(), + ]); + const tlspubkeys = tlspubkeys0 || []; let fullNameForm; let fullNameFieldset; let fullName; @@ -1431,7 +1465,104 @@ const index = async () => { } await check(passwordFieldset, client.SetPassword(password1.value)); passwordForm.reset(); - }), dom.br(), dom.h2('Disk usage'), dom.p('Storage used is ', dom.b(formatQuotaSize(Math.floor(storageUsed / (1024 * 1024)) * 1024 * 1024)), storageLimit > 0 ? [ + }), dom.br(), dom.h2('TLS public keys'), dom.p('For TLS client authentication with certificates, for IMAP and/or submission (SMTP). Only the public key of the certificate is used during TLS authentication, to identify this account. Names, expiration or constraints are not verified.'), (() => { + let elem = dom.div(); + const preauthHelp = 'New IMAP immediate TLS connections authenticated with a client certificate are automatically switched to "authenticated" state with an untagged IMAP "preauth" message by default. IMAP connections have a state machine specifying when commands are allowed. Authenticating is not allowed while in the "authenticated" state. Enable this option to work around clients that would try to authenticated anyway.'; + const render = () => { + const e = dom.div(dom.table(dom.thead(dom.tr(dom.th('Login address'), dom.th('Name'), dom.th('Type'), dom.th('No IMAP "preauth"', attr.title(preauthHelp)), dom.th('Fingerprint'), dom.th('Update'), dom.th('Remove'))), dom.tbody(tlspubkeys.length === 0 ? dom.tr(dom.td(attr.colspan('7'), 'None')) : [], tlspubkeys.map((tpk, index) => { + let loginAddress; + let name; + let noIMAPPreauth; + let update; + const formID = 'tlk-' + index; + const row = dom.tr(dom.td(dom.form(attr.id(formID), async function submit(e) { + e.stopPropagation(); + e.preventDefault(); + const ntpk = { ...tpk }; + ntpk.LoginAddress = loginAddress.value; + ntpk.Name = name.value; + ntpk.NoIMAPPreauth = noIMAPPreauth.checked; + await check(update, client.TLSPublicKeyUpdate(ntpk)); + tpk.LoginAddress = ntpk.LoginAddress; + tpk.Name = ntpk.Name; + tpk.NoIMAPPreauth = ntpk.NoIMAPPreauth; + }, loginAddress = dom.input(attr.type('email'), attr.value(tpk.LoginAddress), attr.required('')))), dom.td(name = dom.input(attr.form(formID), attr.value(tpk.Name), attr.required(''))), dom.td(tpk.Type), dom.td(dom.label(noIMAPPreauth = dom.input(attr.form(formID), attr.type('checkbox'), tpk.NoIMAPPreauth ? attr.checked('') : []), ' No IMAP "preauth"', attr.title(preauthHelp))), dom.td(tpk.Fingerprint), dom.td(update = dom.submitbutton(attr.form(formID), 'Update')), dom.td(dom.form(async function submit(e) { + e.stopPropagation(); + e.preventDefault(); + await check(e.target, client.TLSPublicKeyRemove(tpk.Fingerprint)); + tlspubkeys.splice(tlspubkeys.indexOf(tpk), 1); + render(); + }, dom.submitbutton('Remove')))); + return row; + }))), dom.clickbutton('Add', style({ marginTop: '1ex' }), function click() { + let address; + let name; + let noIMAPPreauth; + let file; + const close = popup(dom.div(style({ maxWidth: '45em' }), dom.h1('Add TLS public key'), dom.form(async function submit(e) { + e.preventDefault(); + e.stopPropagation(); + if (file.files?.length !== 1) { + throw new Error('exactly 1 certificate required'); // xxx + } + const certPEM = await new Promise((resolve, reject) => { + const fr = new window.FileReader(); + fr.addEventListener('load', () => { + resolve(fr.result); + }); + fr.addEventListener('error', () => { + reject(fr.error); + }); + fr.readAsText(file.files[0]); + }); + const ntpk = await check(e.target, client.TLSPublicKeyAdd(address.value, name.value, noIMAPPreauth.checked, certPEM)); + tlspubkeys.push(ntpk); + render(); + close(); + }, dom.label(style({ display: 'block', marginBottom: '1ex' }), dom.div(dom.b('Login address')), address = dom.input(attr.type('email'), attr.value(localStorageGet('webaccountaddress') || ''), attr.required('')), dom.div(style({ fontStyle: 'italic', marginTop: '.5ex' }), 'Login address used for sessions using this key.')), dom.label(style({ display: 'block', marginBottom: '1ex' }), noIMAPPreauth = dom.input(attr.type('checkbox')), ' No IMAP "preauth"', attr.title(preauthHelp)), dom.div(style({ display: 'block', marginBottom: '1ex' }), dom.label(dom.div(dom.b('Certificate')), file = dom.input(attr.type('file'), attr.required(''))), dom.p(style({ fontStyle: 'italic', margin: '1ex 0' }), 'Upload a PEM file containing a certificate, not a private key. Only the public key of the certificate is used during TLS authentication, to identify this account. Names, expiration, and constraints are not verified. ', dom.a('Show suggested commands', attr.href(''), function click(e) { + e.preventDefault(); + popup(dom.h1('Generate a private key and certificate'), dom.pre(dom._class('literal'), `export keyname=... # Used for file names, certificate "common name" and as name of tls public key. + # Suggestion: Use an application name and/or email address. +export passphrase=... # Protects the private key in the PEM and p12 files. + +# Generate an ECDSA P-256 private key and a long-lived, unsigned, basic certificate +# for the corresponding public key. +openssl req \\ + -config /dev/null \\ + -x509 \\ + -newkey ec \\ + -pkeyopt ec_paramgen_curve:P-256 \\ + -passout env:passphrase \\ + -keyout "$keyname.ecdsa-p256.privatekey.pkcs8.pem" \\ + -out "$keyname.ecdsa-p256.certificate.pem" \\ + -days 36500 \\ + -subj "/CN=$keyname" + +# Generate a p12 file containing both certificate and private key, for +# applications/operating systems that cannot read PEM files with +# certificates/private keys. +openssl pkcs12 \\ + -export \\ + -in "$keyname.ecdsa-p256.certificate.pem" \\ + -inkey "$keyname.ecdsa-p256.privatekey.pkcs8.pem" \\ + -name "$keyname" \\ + -passin env:passphrase \\ + -passout env:passphrase \\ + -out "$keyname.ecdsa-p256-privatekey-certificate.p12" + +# If the p12 file cannot be imported in the destination OS or email application, +# try adding -legacy to the "openssl pkcs12" command. +`)); + }), ' for generating a private key and certificate.')), dom.label(style({ display: 'block', marginBottom: '1ex' }), dom.div(dom.b('Name')), name = dom.input(), dom.div(style({ fontStyle: 'italic', marginTop: '.5ex' }), 'Optional. If empty, the "subject common name" from the certificate is used.')), dom.br(), dom.submitbutton('Add')))); + })); + if (elem) { + elem.replaceWith(e); + } + elem = e; + }; + render(); + return elem; + })(), dom.br(), dom.h2('Disk usage'), dom.p('Storage used is ', dom.b(formatQuotaSize(Math.floor(storageUsed / (1024 * 1024)) * 1024 * 1024)), storageLimit > 0 ? [ dom.b('/', formatQuotaSize(storageLimit)), ' (', '' + Math.floor(100 * storageUsed / storageLimit), diff --git a/webaccount/account.ts b/webaccount/account.ts index 514429964..5a0df40bb 100644 --- a/webaccount/account.ts +++ b/webaccount/account.ts @@ -298,7 +298,11 @@ const formatQuotaSize = (v: number) => { } const index = async () => { - const [acc, storageUsed, storageLimit, suppressions] = await client.Account() + const [[acc, storageUsed, storageLimit, suppressions], tlspubkeys0] = await Promise.all([ + client.Account(), + client.TLSPublicKeys(), + ]) + const tlspubkeys = tlspubkeys0 || [] let fullNameForm: HTMLFormElement let fullNameFieldset: HTMLFieldSetElement @@ -601,7 +605,7 @@ const index = async () => { dom.div( dom.h2('Parameters'), dom.div( - style({marginBottom: '.5ex'}), + style({marginBottom: '.5ex'}), dom.label( 'Event', dom.div( @@ -872,6 +876,199 @@ const index = async () => { ), dom.br(), + dom.h2('TLS public keys'), + dom.p('For TLS client authentication with certificates, for IMAP and/or submission (SMTP). Only the public key of the certificate is used during TLS authentication, to identify this account. Names, expiration or constraints are not verified.'), + (() => { + let elem = dom.div() + + const preauthHelp = 'New IMAP immediate TLS connections authenticated with a client certificate are automatically switched to "authenticated" state with an untagged IMAP "preauth" message by default. IMAP connections have a state machine specifying when commands are allowed. Authenticating is not allowed while in the "authenticated" state. Enable this option to work around clients that would try to authenticated anyway.' + + const render = () => { + const e = dom.div( + dom.table( + dom.thead( + dom.tr( + dom.th('Login address'), + dom.th('Name'), + dom.th('Type'), + dom.th('No IMAP "preauth"', attr.title(preauthHelp)), + dom.th('Fingerprint'), + dom.th('Update'), + dom.th('Remove'), + ), + ), + dom.tbody( + tlspubkeys.length === 0 ? dom.tr(dom.td(attr.colspan('7'), 'None')) : [], + tlspubkeys.map((tpk, index) => { + let loginAddress: HTMLInputElement + let name: HTMLInputElement + let noIMAPPreauth: HTMLInputElement + let update: HTMLButtonElement + + const formID = 'tlk-'+index + const row = dom.tr( + dom.td( + dom.form( + attr.id(formID), + async function submit(e: SubmitEvent) { + e.stopPropagation() + e.preventDefault() + + const ntpk: api.TLSPublicKey = {...tpk} + ntpk.LoginAddress = loginAddress.value + ntpk.Name = name.value + ntpk.NoIMAPPreauth = noIMAPPreauth.checked + await check(update, client.TLSPublicKeyUpdate(ntpk)) + tpk.LoginAddress = ntpk.LoginAddress + tpk.Name = ntpk.Name + tpk.NoIMAPPreauth = ntpk.NoIMAPPreauth + }, + loginAddress=dom.input(attr.type('email'), attr.value(tpk.LoginAddress), attr.required('')), + ), + ), + dom.td(name=dom.input(attr.form(formID), attr.value(tpk.Name), attr.required(''))), + dom.td(tpk.Type), + dom.td(dom.label(noIMAPPreauth=dom.input(attr.form(formID), attr.type('checkbox'), tpk.NoIMAPPreauth ? attr.checked('') : []), ' No IMAP "preauth"', attr.title(preauthHelp))), + dom.td(tpk.Fingerprint), + dom.td(update=dom.submitbutton(attr.form(formID), 'Update')), + dom.td( + dom.form( + async function submit(e: SubmitEvent & {target: {disabled: boolean}}) { + e.stopPropagation() + e.preventDefault() + await check(e.target, client.TLSPublicKeyRemove(tpk.Fingerprint)) + tlspubkeys.splice(tlspubkeys.indexOf(tpk), 1) + render() + }, + dom.submitbutton('Remove'), + ), + ), + ) + return row + }), + ), + ), + dom.clickbutton('Add', style({marginTop: '1ex'}), function click() { + let address: HTMLInputElement + let name: HTMLInputElement + let noIMAPPreauth: HTMLInputElement + let file: HTMLInputElement + + const close = popup( + dom.div( + style({maxWidth: '45em'}), + dom.h1('Add TLS public key'), + dom.form( + async function submit(e: SubmitEvent & {target: {disabled: boolean}}) { + e.preventDefault() + e.stopPropagation() + if (file.files?.length !== 1) { + throw new Error('exactly 1 certificate required') // xxx + } + const certPEM = await new Promise((resolve, reject) => { + const fr = new window.FileReader() + fr.addEventListener('load', () => { + resolve(fr.result as string) + }) + fr.addEventListener('error', () => { + reject(fr.error) + }) + fr.readAsText(file.files![0]) + }) + const ntpk = await check(e.target, client.TLSPublicKeyAdd(address.value, name.value, noIMAPPreauth.checked, certPEM)) + tlspubkeys.push(ntpk) + render() + close() + }, + dom.label( + style({display: 'block', marginBottom: '1ex'}), + dom.div(dom.b('Login address')), + address=dom.input(attr.type('email'), attr.value(localStorageGet('webaccountaddress') || ''), attr.required('')), + dom.div(style({fontStyle: 'italic', marginTop: '.5ex'}), 'Login address used for sessions using this key.'), + ), + dom.label( + style({display: 'block', marginBottom: '1ex'}), + noIMAPPreauth=dom.input(attr.type('checkbox')), + ' No IMAP "preauth"', + attr.title(preauthHelp), + ), + dom.div( + style({display: 'block', marginBottom: '1ex'}), + dom.label( + dom.div(dom.b('Certificate')), + file=dom.input(attr.type('file'), attr.required('')), + ), + dom.p( + style({fontStyle: 'italic', margin: '1ex 0'}), + 'Upload a PEM file containing a certificate, not a private key. Only the public key of the certificate is used during TLS authentication, to identify this account. Names, expiration, and constraints are not verified. ', + dom.a('Show suggested commands', attr.href(''), function click(e: MouseEvent) { + e.preventDefault() + popup( + dom.h1('Generate a private key and certificate'), + dom.pre( + dom._class('literal'), +`export keyname=... # Used for file names, certificate "common name" and as name of tls public key. + # Suggestion: Use an application name and/or email address. +export passphrase=... # Protects the private key in the PEM and p12 files. + +# Generate an ECDSA P-256 private key and a long-lived, unsigned, basic certificate +# for the corresponding public key. +openssl req \\ + -config /dev/null \\ + -x509 \\ + -newkey ec \\ + -pkeyopt ec_paramgen_curve:P-256 \\ + -passout env:passphrase \\ + -keyout "$keyname.ecdsa-p256.privatekey.pkcs8.pem" \\ + -out "$keyname.ecdsa-p256.certificate.pem" \\ + -days 36500 \\ + -subj "/CN=$keyname" + +# Generate a p12 file containing both certificate and private key, for +# applications/operating systems that cannot read PEM files with +# certificates/private keys. +openssl pkcs12 \\ + -export \\ + -in "$keyname.ecdsa-p256.certificate.pem" \\ + -inkey "$keyname.ecdsa-p256.privatekey.pkcs8.pem" \\ + -name "$keyname" \\ + -passin env:passphrase \\ + -passout env:passphrase \\ + -out "$keyname.ecdsa-p256-privatekey-certificate.p12" + +# If the p12 file cannot be imported in the destination OS or email application, +# try adding -legacy to the "openssl pkcs12" command. +` + ), + ) + }), + ' for generating a private key and certificate.', + ), + ), + dom.label( + style({display: 'block', marginBottom: '1ex'}), + dom.div(dom.b('Name')), + name=dom.input(), + dom.div(style({fontStyle: 'italic', marginTop: '.5ex'}), 'Optional. If empty, the "subject common name" from the certificate is used.'), + ), + dom.br(), + dom.submitbutton('Add'), + ), + ), + ) + }) + ) + + if (elem) { + elem.replaceWith(e) + } + elem = e + } + render() + return elem + })(), + dom.br(), + dom.h2('Disk usage'), dom.p('Storage used is ', dom.b(formatQuotaSize(Math.floor(storageUsed/(1024*1024))*1024*1024)), storageLimit > 0 ? [ diff --git a/webaccount/account_test.go b/webaccount/account_test.go index f2b2e2335..d4ce9a13e 100644 --- a/webaccount/account_test.go +++ b/webaccount/account_test.go @@ -6,9 +6,14 @@ import ( "bytes" "compress/gzip" "context" + "crypto/ed25519" + cryptorand "crypto/rand" + "crypto/x509" "encoding/json" + "encoding/pem" "fmt" "io" + "math/big" "mime/multipart" "net/http" "net/http/httptest" @@ -484,6 +489,65 @@ func TestAccount(t *testing.T) { api.RejectsSave(ctx, "Rejects", false) api.RejectsSave(ctx, "", false) // Restore. + // Make cert for TLSPublicKey. + certBuf := fakeCert(t) + var b bytes.Buffer + err = pem.Encode(&b, &pem.Block{Type: "CERTIFICATE", Bytes: certBuf}) + tcheck(t, err, "encoding certificate as pem") + certPEM := b.String() + + err = store.Init(ctx) + tcheck(t, err, "store init") + defer func() { + err := store.Close() + tcheck(t, err, "store close") + }() + + tpkl, err := api.TLSPublicKeys(ctx) + tcheck(t, err, "list tls public keys") + tcompare(t, len(tpkl), 0) + + tpk, err := api.TLSPublicKeyAdd(ctx, "mjl☺@mox.example", "", false, certPEM) + tcheck(t, err, "add tls public key") + // Key already exists. + tneedErrorCode(t, "user:error", func() { api.TLSPublicKeyAdd(ctx, "mjl☺@mox.example", "", false, certPEM) }) + + tpkl, err = api.TLSPublicKeys(ctx) + tcheck(t, err, "list tls public keys") + tcompare(t, tpkl, []store.TLSPublicKey{tpk}) + + tpk.NoIMAPPreauth = true + err = api.TLSPublicKeyUpdate(ctx, tpk) + tcheck(t, err, "tls public key update") + badtpk := tpk + badtpk.Fingerprint = "bogus" + tneedErrorCode(t, "user:error", func() { api.TLSPublicKeyUpdate(ctx, badtpk) }) + + tpkl, err = api.TLSPublicKeys(ctx) + tcheck(t, err, "list tls public keys") + tcompare(t, len(tpkl), 1) + tcompare(t, tpkl[0].NoIMAPPreauth, true) + + err = api.TLSPublicKeyRemove(ctx, tpk.Fingerprint) + tcheck(t, err, "tls public key remove") + tneedErrorCode(t, "user:error", func() { api.TLSPublicKeyRemove(ctx, tpk.Fingerprint) }) + + tpkl, err = api.TLSPublicKeys(ctx) + tcheck(t, err, "list tls public keys") + tcompare(t, len(tpkl), 0) + api.Logout(ctx) tneedErrorCode(t, "server:error", func() { api.Logout(ctx) }) } + +func fakeCert(t *testing.T) []byte { + t.Helper() + seed := make([]byte, ed25519.SeedSize) + privKey := ed25519.NewKeyFromSeed(seed) // Fake key, don't use this for real! + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), // Required field... + } + localCertBuf, err := x509.CreateCertificate(cryptorand.Reader, template, template, privKey.Public(), privKey) + tcheck(t, err, "making certificate") + return localCertBuf +} diff --git a/webaccount/api.json b/webaccount/api.json index 5eca17452..cbae88d5f 100644 --- a/webaccount/api.json +++ b/webaccount/api.json @@ -450,6 +450,84 @@ } ], "Returns": [] + }, + { + "Name": "TLSPublicKeys", + "Docs": "", + "Params": [], + "Returns": [ + { + "Name": "r0", + "Typewords": [ + "[]", + "TLSPublicKey" + ] + } + ] + }, + { + "Name": "TLSPublicKeyAdd", + "Docs": "", + "Params": [ + { + "Name": "loginAddress", + "Typewords": [ + "string" + ] + }, + { + "Name": "name", + "Typewords": [ + "string" + ] + }, + { + "Name": "noIMAPPreauth", + "Typewords": [ + "bool" + ] + }, + { + "Name": "certPEM", + "Typewords": [ + "string" + ] + } + ], + "Returns": [ + { + "Name": "r0", + "Typewords": [ + "TLSPublicKey" + ] + } + ] + }, + { + "Name": "TLSPublicKeyRemove", + "Docs": "", + "Params": [ + { + "Name": "fingerprint", + "Typewords": [ + "string" + ] + } + ], + "Returns": [] + }, + { + "Name": "TLSPublicKeyUpdate", + "Docs": "", + "Params": [ + { + "Name": "pubKey", + "Typewords": [ + "TLSPublicKey" + ] + } + ], + "Returns": [] } ], "Sections": [], @@ -1510,6 +1588,69 @@ ] } ] + }, + { + "Name": "TLSPublicKey", + "Docs": "TLSPublicKey is a public key for use with TLS client authentication based on the\npublic key of the certificate.", + "Fields": [ + { + "Name": "Fingerprint", + "Docs": "Raw-url-base64-encoded Subject Public Key Info of certificate.", + "Typewords": [ + "string" + ] + }, + { + "Name": "Created", + "Docs": "", + "Typewords": [ + "timestamp" + ] + }, + { + "Name": "Type", + "Docs": "E.g. \"rsa-2048\", \"ecdsa-p256\", \"ed25519\"", + "Typewords": [ + "string" + ] + }, + { + "Name": "Name", + "Docs": "Descriptive name to identify the key, e.g. the device where key is used.", + "Typewords": [ + "string" + ] + }, + { + "Name": "NoIMAPPreauth", + "Docs": "If set, new immediate authenticated TLS connections are not moved to \"authenticated\" state. For clients that don't understand it, and will try an authenticate command anyway.", + "Typewords": [ + "bool" + ] + }, + { + "Name": "CertDER", + "Docs": "", + "Typewords": [ + "[]", + "uint8" + ] + }, + { + "Name": "Account", + "Docs": "Key authenticates this account.", + "Typewords": [ + "string" + ] + }, + { + "Name": "LoginAddress", + "Docs": "Must belong to account.", + "Typewords": [ + "string" + ] + } + ] } ], "Ints": [], diff --git a/webaccount/api.ts b/webaccount/api.ts index 1ba09f677..b90af74ff 100644 --- a/webaccount/api.ts +++ b/webaccount/api.ts @@ -204,6 +204,19 @@ export interface IncomingMeta { Automated: boolean // Whether this message was automated and should not receive automated replies. E.g. out of office or mailing list messages. } +// TLSPublicKey is a public key for use with TLS client authentication based on the +// public key of the certificate. +export interface TLSPublicKey { + Fingerprint: string // Raw-url-base64-encoded Subject Public Key Info of certificate. + Created: Date + Type: string // E.g. "rsa-2048", "ecdsa-p256", "ed25519" + Name: string // Descriptive name to identify the key, e.g. the device where key is used. + NoIMAPPreauth: boolean // If set, new immediate authenticated TLS connections are not moved to "authenticated" state. For clients that don't understand it, and will try an authenticate command anyway. + CertDER?: string | null + Account: string // Key authenticates this account. + LoginAddress: string // Must belong to account. +} + export type CSRFToken = string // Localpart is a decoded local part of an email address, before the "@". @@ -238,7 +251,7 @@ export enum OutgoingEvent { EventUnrecognized = "unrecognized", } -export const structTypes: {[typename: string]: boolean} = {"Account":true,"Address":true,"AddressAlias":true,"Alias":true,"AliasAddress":true,"AutomaticJunkFlags":true,"Destination":true,"Domain":true,"ImportProgress":true,"Incoming":true,"IncomingMeta":true,"IncomingWebhook":true,"JunkFilter":true,"NameAddress":true,"Outgoing":true,"OutgoingWebhook":true,"Route":true,"Ruleset":true,"Structure":true,"SubjectPass":true,"Suppression":true} +export const structTypes: {[typename: string]: boolean} = {"Account":true,"Address":true,"AddressAlias":true,"Alias":true,"AliasAddress":true,"AutomaticJunkFlags":true,"Destination":true,"Domain":true,"ImportProgress":true,"Incoming":true,"IncomingMeta":true,"IncomingWebhook":true,"JunkFilter":true,"NameAddress":true,"Outgoing":true,"OutgoingWebhook":true,"Route":true,"Ruleset":true,"Structure":true,"SubjectPass":true,"Suppression":true,"TLSPublicKey":true} export const stringsTypes: {[typename: string]: boolean} = {"CSRFToken":true,"Localpart":true,"OutgoingEvent":true} export const intsTypes: {[typename: string]: boolean} = {} export const types: TypenameMap = { @@ -263,6 +276,7 @@ export const types: TypenameMap = { "NameAddress": {"Name":"NameAddress","Docs":"","Fields":[{"Name":"Name","Docs":"","Typewords":["string"]},{"Name":"Address","Docs":"","Typewords":["string"]}]}, "Structure": {"Name":"Structure","Docs":"","Fields":[{"Name":"ContentType","Docs":"","Typewords":["string"]},{"Name":"ContentTypeParams","Docs":"","Typewords":["{}","string"]},{"Name":"ContentID","Docs":"","Typewords":["string"]},{"Name":"DecodedSize","Docs":"","Typewords":["int64"]},{"Name":"Parts","Docs":"","Typewords":["[]","Structure"]}]}, "IncomingMeta": {"Name":"IncomingMeta","Docs":"","Fields":[{"Name":"MsgID","Docs":"","Typewords":["int64"]},{"Name":"MailFrom","Docs":"","Typewords":["string"]},{"Name":"MailFromValidated","Docs":"","Typewords":["bool"]},{"Name":"MsgFromValidated","Docs":"","Typewords":["bool"]},{"Name":"RcptTo","Docs":"","Typewords":["string"]},{"Name":"DKIMVerifiedDomains","Docs":"","Typewords":["[]","string"]},{"Name":"RemoteIP","Docs":"","Typewords":["string"]},{"Name":"Received","Docs":"","Typewords":["timestamp"]},{"Name":"MailboxName","Docs":"","Typewords":["string"]},{"Name":"Automated","Docs":"","Typewords":["bool"]}]}, + "TLSPublicKey": {"Name":"TLSPublicKey","Docs":"","Fields":[{"Name":"Fingerprint","Docs":"","Typewords":["string"]},{"Name":"Created","Docs":"","Typewords":["timestamp"]},{"Name":"Type","Docs":"","Typewords":["string"]},{"Name":"Name","Docs":"","Typewords":["string"]},{"Name":"NoIMAPPreauth","Docs":"","Typewords":["bool"]},{"Name":"CertDER","Docs":"","Typewords":["nullable","string"]},{"Name":"Account","Docs":"","Typewords":["string"]},{"Name":"LoginAddress","Docs":"","Typewords":["string"]}]}, "CSRFToken": {"Name":"CSRFToken","Docs":"","Values":null}, "Localpart": {"Name":"Localpart","Docs":"","Values":null}, "OutgoingEvent": {"Name":"OutgoingEvent","Docs":"","Values":[{"Name":"EventDelivered","Value":"delivered","Docs":""},{"Name":"EventSuppressed","Value":"suppressed","Docs":""},{"Name":"EventDelayed","Value":"delayed","Docs":""},{"Name":"EventFailed","Value":"failed","Docs":""},{"Name":"EventRelayed","Value":"relayed","Docs":""},{"Name":"EventExpanded","Value":"expanded","Docs":""},{"Name":"EventCanceled","Value":"canceled","Docs":""},{"Name":"EventUnrecognized","Value":"unrecognized","Docs":""}]}, @@ -290,6 +304,7 @@ export const parser = { NameAddress: (v: any) => parse("NameAddress", v) as NameAddress, Structure: (v: any) => parse("Structure", v) as Structure, IncomingMeta: (v: any) => parse("IncomingMeta", v) as IncomingMeta, + TLSPublicKey: (v: any) => parse("TLSPublicKey", v) as TLSPublicKey, CSRFToken: (v: any) => parse("CSRFToken", v) as CSRFToken, Localpart: (v: any) => parse("Localpart", v) as Localpart, OutgoingEvent: (v: any) => parse("OutgoingEvent", v) as OutgoingEvent, @@ -535,6 +550,38 @@ export class Client { const params: any[] = [mailbox, keep] return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void } + + async TLSPublicKeys(): Promise { + const fn: string = "TLSPublicKeys" + const paramTypes: string[][] = [] + const returnTypes: string[][] = [["[]","TLSPublicKey"]] + const params: any[] = [] + return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as TLSPublicKey[] | null + } + + async TLSPublicKeyAdd(loginAddress: string, name: string, noIMAPPreauth: boolean, certPEM: string): Promise { + const fn: string = "TLSPublicKeyAdd" + const paramTypes: string[][] = [["string"],["string"],["bool"],["string"]] + const returnTypes: string[][] = [["TLSPublicKey"]] + const params: any[] = [loginAddress, name, noIMAPPreauth, certPEM] + return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as TLSPublicKey + } + + async TLSPublicKeyRemove(fingerprint: string): Promise { + const fn: string = "TLSPublicKeyRemove" + const paramTypes: string[][] = [["string"]] + const returnTypes: string[][] = [] + const params: any[] = [fingerprint] + return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void + } + + async TLSPublicKeyUpdate(pubKey: TLSPublicKey): Promise { + const fn: string = "TLSPublicKeyUpdate" + const paramTypes: string[][] = [["TLSPublicKey"]] + const returnTypes: string[][] = [] + const params: any[] = [pubKey] + return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void + } } export const defaultBaseURL = (function() { diff --git a/webadmin/admin.go b/webadmin/admin.go index 9e091438c..1e1ddc264 100644 --- a/webadmin/admin.go +++ b/webadmin/admin.go @@ -2682,3 +2682,7 @@ func (Admin) AliasAddressesRemove(ctx context.Context, aliaslp string, domainNam err := admin.AliasAddressesRemove(ctx, addr, addresses) xcheckf(ctx, err, "removing address from alias") } + +func (Admin) TLSPublicKeys(ctx context.Context, accountOpt string) ([]store.TLSPublicKey, error) { + return store.TLSPublicKeyList(ctx, accountOpt) +} diff --git a/webadmin/admin.js b/webadmin/admin.js index 627f5b3bf..0435e66bd 100644 --- a/webadmin/admin.js +++ b/webadmin/admin.js @@ -250,7 +250,7 @@ var api; Mode["ModeTesting"] = "testing"; Mode["ModeNone"] = "none"; })(Mode = api.Mode || (api.Mode = {})); - api.structTypes = { "Account": true, "Address": true, "AddressAlias": true, "Alias": true, "AliasAddress": true, "AuthResults": true, "AutoconfCheckResult": true, "AutodiscoverCheckResult": true, "AutodiscoverSRV": true, "AutomaticJunkFlags": true, "Canonicalization": true, "CheckResult": true, "ClientConfigs": true, "ClientConfigsEntry": true, "ConfigDomain": true, "DANECheckResult": true, "DKIM": true, "DKIMAuthResult": true, "DKIMCheckResult": true, "DKIMRecord": true, "DMARC": true, "DMARCCheckResult": true, "DMARCRecord": true, "DMARCSummary": true, "DNSSECResult": true, "DateRange": true, "Destination": true, "Directive": true, "Domain": true, "DomainFeedback": true, "Dynamic": true, "Evaluation": true, "EvaluationStat": true, "Extension": true, "FailureDetails": true, "Filter": true, "HoldRule": true, "Hook": true, "HookFilter": true, "HookResult": true, "HookRetired": true, "HookRetiredFilter": true, "HookRetiredSort": true, "HookSort": true, "IPDomain": true, "IPRevCheckResult": true, "Identifiers": true, "IncomingWebhook": true, "JunkFilter": true, "MTASTS": true, "MTASTSCheckResult": true, "MTASTSRecord": true, "MX": true, "MXCheckResult": true, "Modifier": true, "Msg": true, "MsgResult": true, "MsgRetired": true, "OutgoingWebhook": true, "Pair": true, "Policy": true, "PolicyEvaluated": true, "PolicyOverrideReason": true, "PolicyPublished": true, "PolicyRecord": true, "Record": true, "Report": true, "ReportMetadata": true, "ReportRecord": true, "Result": true, "ResultPolicy": true, "RetiredFilter": true, "RetiredSort": true, "Reverse": true, "Route": true, "Row": true, "Ruleset": true, "SMTPAuth": true, "SPFAuthResult": true, "SPFCheckResult": true, "SPFRecord": true, "SRV": true, "SRVConfCheckResult": true, "STSMX": true, "Selector": true, "Sort": true, "SubjectPass": true, "Summary": true, "SuppressAddress": true, "TLSCheckResult": true, "TLSRPT": true, "TLSRPTCheckResult": true, "TLSRPTDateRange": true, "TLSRPTRecord": true, "TLSRPTSummary": true, "TLSRPTSuppressAddress": true, "TLSReportRecord": true, "TLSResult": true, "Transport": true, "TransportDirect": true, "TransportSMTP": true, "TransportSocks": true, "URI": true, "WebForward": true, "WebHandler": true, "WebInternal": true, "WebRedirect": true, "WebStatic": true, "WebserverConfig": true }; + api.structTypes = { "Account": true, "Address": true, "AddressAlias": true, "Alias": true, "AliasAddress": true, "AuthResults": true, "AutoconfCheckResult": true, "AutodiscoverCheckResult": true, "AutodiscoverSRV": true, "AutomaticJunkFlags": true, "Canonicalization": true, "CheckResult": true, "ClientConfigs": true, "ClientConfigsEntry": true, "ConfigDomain": true, "DANECheckResult": true, "DKIM": true, "DKIMAuthResult": true, "DKIMCheckResult": true, "DKIMRecord": true, "DMARC": true, "DMARCCheckResult": true, "DMARCRecord": true, "DMARCSummary": true, "DNSSECResult": true, "DateRange": true, "Destination": true, "Directive": true, "Domain": true, "DomainFeedback": true, "Dynamic": true, "Evaluation": true, "EvaluationStat": true, "Extension": true, "FailureDetails": true, "Filter": true, "HoldRule": true, "Hook": true, "HookFilter": true, "HookResult": true, "HookRetired": true, "HookRetiredFilter": true, "HookRetiredSort": true, "HookSort": true, "IPDomain": true, "IPRevCheckResult": true, "Identifiers": true, "IncomingWebhook": true, "JunkFilter": true, "MTASTS": true, "MTASTSCheckResult": true, "MTASTSRecord": true, "MX": true, "MXCheckResult": true, "Modifier": true, "Msg": true, "MsgResult": true, "MsgRetired": true, "OutgoingWebhook": true, "Pair": true, "Policy": true, "PolicyEvaluated": true, "PolicyOverrideReason": true, "PolicyPublished": true, "PolicyRecord": true, "Record": true, "Report": true, "ReportMetadata": true, "ReportRecord": true, "Result": true, "ResultPolicy": true, "RetiredFilter": true, "RetiredSort": true, "Reverse": true, "Route": true, "Row": true, "Ruleset": true, "SMTPAuth": true, "SPFAuthResult": true, "SPFCheckResult": true, "SPFRecord": true, "SRV": true, "SRVConfCheckResult": true, "STSMX": true, "Selector": true, "Sort": true, "SubjectPass": true, "Summary": true, "SuppressAddress": true, "TLSCheckResult": true, "TLSPublicKey": true, "TLSRPT": true, "TLSRPTCheckResult": true, "TLSRPTDateRange": true, "TLSRPTRecord": true, "TLSRPTSummary": true, "TLSRPTSuppressAddress": true, "TLSReportRecord": true, "TLSResult": true, "Transport": true, "TransportDirect": true, "TransportSMTP": true, "TransportSocks": true, "URI": true, "WebForward": true, "WebHandler": true, "WebInternal": true, "WebRedirect": true, "WebStatic": true, "WebserverConfig": true }; api.stringsTypes = { "Align": true, "CSRFToken": true, "DMARCPolicy": true, "IP": true, "Localpart": true, "Mode": true, "RUA": true }; api.intsTypes = {}; api.types = { @@ -363,6 +363,7 @@ var api; "TLSResult": { "Name": "TLSResult", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "PolicyDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "DayUTC", "Docs": "", "Typewords": ["string"] }, { "Name": "RecipientDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "Created", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "Updated", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "IsHost", "Docs": "", "Typewords": ["bool"] }, { "Name": "SendReport", "Docs": "", "Typewords": ["bool"] }, { "Name": "SentToRecipientDomain", "Docs": "", "Typewords": ["bool"] }, { "Name": "RecipientDomainReportingAddresses", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "SentToPolicyDomain", "Docs": "", "Typewords": ["bool"] }, { "Name": "Results", "Docs": "", "Typewords": ["[]", "Result"] }] }, "TLSRPTSuppressAddress": { "Name": "TLSRPTSuppressAddress", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Inserted", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "ReportingAddress", "Docs": "", "Typewords": ["string"] }, { "Name": "Until", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "Comment", "Docs": "", "Typewords": ["string"] }] }, "Dynamic": { "Name": "Dynamic", "Docs": "", "Fields": [{ "Name": "Domains", "Docs": "", "Typewords": ["{}", "ConfigDomain"] }, { "Name": "Accounts", "Docs": "", "Typewords": ["{}", "Account"] }, { "Name": "WebDomainRedirects", "Docs": "", "Typewords": ["{}", "string"] }, { "Name": "WebHandlers", "Docs": "", "Typewords": ["[]", "WebHandler"] }, { "Name": "Routes", "Docs": "", "Typewords": ["[]", "Route"] }, { "Name": "MonitorDNSBLs", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "MonitorDNSBLZones", "Docs": "", "Typewords": ["[]", "Domain"] }] }, + "TLSPublicKey": { "Name": "TLSPublicKey", "Docs": "", "Fields": [{ "Name": "Fingerprint", "Docs": "", "Typewords": ["string"] }, { "Name": "Created", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "Type", "Docs": "", "Typewords": ["string"] }, { "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "NoIMAPPreauth", "Docs": "", "Typewords": ["bool"] }, { "Name": "CertDER", "Docs": "", "Typewords": ["nullable", "string"] }, { "Name": "Account", "Docs": "", "Typewords": ["string"] }, { "Name": "LoginAddress", "Docs": "", "Typewords": ["string"] }] }, "CSRFToken": { "Name": "CSRFToken", "Docs": "", "Values": null }, "DMARCPolicy": { "Name": "DMARCPolicy", "Docs": "", "Values": [{ "Name": "PolicyEmpty", "Value": "", "Docs": "" }, { "Name": "PolicyNone", "Value": "none", "Docs": "" }, { "Name": "PolicyQuarantine", "Value": "quarantine", "Docs": "" }, { "Name": "PolicyReject", "Value": "reject", "Docs": "" }] }, "Align": { "Name": "Align", "Docs": "", "Values": [{ "Name": "AlignStrict", "Value": "s", "Docs": "" }, { "Name": "AlignRelaxed", "Value": "r", "Docs": "" }] }, @@ -481,6 +482,7 @@ var api; TLSResult: (v) => api.parse("TLSResult", v), TLSRPTSuppressAddress: (v) => api.parse("TLSRPTSuppressAddress", v), Dynamic: (v) => api.parse("Dynamic", v), + TLSPublicKey: (v) => api.parse("TLSPublicKey", v), CSRFToken: (v) => api.parse("CSRFToken", v), DMARCPolicy: (v) => api.parse("DMARCPolicy", v), Align: (v) => api.parse("Align", v), @@ -1291,6 +1293,13 @@ var api; const params = [aliaslp, domainName, addresses]; return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); } + async TLSPublicKeys(accountOpt) { + const fn = "TLSPublicKeys"; + const paramTypes = [["string"]]; + const returnTypes = [["[]", "TLSPublicKey"]]; + const params = [accountOpt]; + return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); + } } api.Client = Client; api.defaultBaseURL = (function () { @@ -2091,10 +2100,11 @@ const RoutesEditor = (kind, transports, routes, save) => { return render(); }; const account = async (name) => { - const [[config, diskUsage], domains, transports] = await Promise.all([ + const [[config, diskUsage], domains, transports, tlspubkeys] = await Promise.all([ client.Account(name), client.Domains(), client.Transports(), + client.TLSPublicKeys(name), ]); // todo: show suppression list, and buttons to add/remove entries. let form; @@ -2194,7 +2204,10 @@ const account = async (name) => { await check(fieldsetPassword, client.SetPassword(name, password.value)); window.alert('Password has been changed.'); formPassword.reset(); - }), dom.br(), RoutesEditor('account-specific', transports, config.Routes || [], async (routes) => await client.AccountRoutesSave(name, routes)), dom.br(), dom.h2('Danger'), dom.clickbutton('Remove account', async function click(e) { + }), dom.br(), dom.h2('TLS public keys', attr.title('For TLS client authentication with certificates, for IMAP and/or submission (SMTP). Only the public key of the certificate is used during TLS authentication, to identify this account. Names, expiration or constraints are not verified.')), dom.table(dom.thead(dom.tr(dom.th('Login address'), dom.th('Name'), dom.th('Type'), dom.th('No IMAP "preauth"', attr.title('New IMAP immediate TLS connections authenticated with a client certificate are automatically switched to "authenticated" state with an untagged IMAP "preauth" message by default. IMAP connections have a state machine specifying when commands are allowed. Authenticating is not allowed while in the "authenticated" state. Enable this option to work around clients that would try to authenticated anyway.')), dom.th('Fingerprint'))), dom.tbody(tlspubkeys?.length ? [] : dom.tr(dom.td(attr.colspan('5'), 'None')), (tlspubkeys || []).map(tpk => { + const row = dom.tr(dom.td(tpk.LoginAddress), dom.td(tpk.Name), dom.td(tpk.Type), dom.td(tpk.NoIMAPPreauth ? 'Enabled' : ''), dom.td(tpk.Fingerprint)); + return row; + }))), dom.br(), RoutesEditor('account-specific', transports, config.Routes || [], async (routes) => await client.AccountRoutesSave(name, routes)), dom.br(), dom.h2('Danger'), dom.clickbutton('Remove account', async function click(e) { e.preventDefault(); if (!window.confirm('Are you sure you want to remove this account? All account data, including messages will be removed.')) { return; diff --git a/webadmin/admin.ts b/webadmin/admin.ts index 4d1e47d91..8fdb2788b 100644 --- a/webadmin/admin.ts +++ b/webadmin/admin.ts @@ -759,10 +759,11 @@ const RoutesEditor = (kind: string, transports: { [key: string]: api.Transport } } const account = async (name: string) => { - const [[config, diskUsage], domains, transports] = await Promise.all([ + const [[config, diskUsage], domains, transports, tlspubkeys] = await Promise.all([ client.Account(name), client.Domains(), client.Transports(), + client.TLSPublicKeys(name), ]) // todo: show suppression list, and buttons to add/remove entries. @@ -994,6 +995,33 @@ const account = async (name: string) => { formPassword.reset() }, ), + dom.br(), + dom.h2('TLS public keys', attr.title('For TLS client authentication with certificates, for IMAP and/or submission (SMTP). Only the public key of the certificate is used during TLS authentication, to identify this account. Names, expiration or constraints are not verified.')), + dom.table( + dom.thead( + dom.tr( + dom.th('Login address'), + dom.th('Name'), + dom.th('Type'), + dom.th('No IMAP "preauth"', attr.title('New IMAP immediate TLS connections authenticated with a client certificate are automatically switched to "authenticated" state with an untagged IMAP "preauth" message by default. IMAP connections have a state machine specifying when commands are allowed. Authenticating is not allowed while in the "authenticated" state. Enable this option to work around clients that would try to authenticated anyway.')), + dom.th('Fingerprint'), + ), + ), + dom.tbody( + tlspubkeys?.length ? [] : dom.tr(dom.td(attr.colspan('5'), 'None')), + (tlspubkeys || []).map(tpk => { + const row = dom.tr( + dom.td(tpk.LoginAddress), + dom.td(tpk.Name), + dom.td(tpk.Type), + dom.td(tpk.NoIMAPPreauth ? 'Enabled' : ''), + dom.td(tpk.Fingerprint), + ) + return row + }), + ), + ), + dom.br(), RoutesEditor('account-specific', transports, config.Routes || [], async (routes: api.Route[]) => await client.AccountRoutesSave(name, routes)), dom.br(), diff --git a/webadmin/api.json b/webadmin/api.json index 9ee43e6a8..28d24c39b 100644 --- a/webadmin/api.json +++ b/webadmin/api.json @@ -2038,6 +2038,27 @@ } ], "Returns": [] + }, + { + "Name": "TLSPublicKeys", + "Docs": "", + "Params": [ + { + "Name": "accountOpt", + "Typewords": [ + "string" + ] + } + ], + "Returns": [ + { + "Name": "r0", + "Typewords": [ + "[]", + "TLSPublicKey" + ] + } + ] } ], "Sections": [], @@ -7239,6 +7260,69 @@ ] } ] + }, + { + "Name": "TLSPublicKey", + "Docs": "TLSPublicKey is a public key for use with TLS client authentication based on the\npublic key of the certificate.", + "Fields": [ + { + "Name": "Fingerprint", + "Docs": "Raw-url-base64-encoded Subject Public Key Info of certificate.", + "Typewords": [ + "string" + ] + }, + { + "Name": "Created", + "Docs": "", + "Typewords": [ + "timestamp" + ] + }, + { + "Name": "Type", + "Docs": "E.g. \"rsa-2048\", \"ecdsa-p256\", \"ed25519\"", + "Typewords": [ + "string" + ] + }, + { + "Name": "Name", + "Docs": "Descriptive name to identify the key, e.g. the device where key is used.", + "Typewords": [ + "string" + ] + }, + { + "Name": "NoIMAPPreauth", + "Docs": "If set, new immediate authenticated TLS connections are not moved to \"authenticated\" state. For clients that don't understand it, and will try an authenticate command anyway.", + "Typewords": [ + "bool" + ] + }, + { + "Name": "CertDER", + "Docs": "", + "Typewords": [ + "[]", + "uint8" + ] + }, + { + "Name": "Account", + "Docs": "Key authenticates this account.", + "Typewords": [ + "string" + ] + }, + { + "Name": "LoginAddress", + "Docs": "Must belong to account.", + "Typewords": [ + "string" + ] + } + ] } ], "Ints": [], diff --git a/webadmin/api.ts b/webadmin/api.ts index d2768c56c..db55d15d5 100644 --- a/webadmin/api.ts +++ b/webadmin/api.ts @@ -1052,6 +1052,19 @@ export interface Dynamic { MonitorDNSBLZones?: Domain[] | null } +// TLSPublicKey is a public key for use with TLS client authentication based on the +// public key of the certificate. +export interface TLSPublicKey { + Fingerprint: string // Raw-url-base64-encoded Subject Public Key Info of certificate. + Created: Date + Type: string // E.g. "rsa-2048", "ecdsa-p256", "ed25519" + Name: string // Descriptive name to identify the key, e.g. the device where key is used. + NoIMAPPreauth: boolean // If set, new immediate authenticated TLS connections are not moved to "authenticated" state. For clients that don't understand it, and will try an authenticate command anyway. + CertDER?: string | null + Account: string // Key authenticates this account. + LoginAddress: string // Must belong to account. +} + export type CSRFToken = string // Policy as used in DMARC DNS record for "p=" or "sp=". @@ -1096,7 +1109,7 @@ export type Localpart = string // be an IPv4 address. export type IP = string -export const structTypes: {[typename: string]: boolean} = {"Account":true,"Address":true,"AddressAlias":true,"Alias":true,"AliasAddress":true,"AuthResults":true,"AutoconfCheckResult":true,"AutodiscoverCheckResult":true,"AutodiscoverSRV":true,"AutomaticJunkFlags":true,"Canonicalization":true,"CheckResult":true,"ClientConfigs":true,"ClientConfigsEntry":true,"ConfigDomain":true,"DANECheckResult":true,"DKIM":true,"DKIMAuthResult":true,"DKIMCheckResult":true,"DKIMRecord":true,"DMARC":true,"DMARCCheckResult":true,"DMARCRecord":true,"DMARCSummary":true,"DNSSECResult":true,"DateRange":true,"Destination":true,"Directive":true,"Domain":true,"DomainFeedback":true,"Dynamic":true,"Evaluation":true,"EvaluationStat":true,"Extension":true,"FailureDetails":true,"Filter":true,"HoldRule":true,"Hook":true,"HookFilter":true,"HookResult":true,"HookRetired":true,"HookRetiredFilter":true,"HookRetiredSort":true,"HookSort":true,"IPDomain":true,"IPRevCheckResult":true,"Identifiers":true,"IncomingWebhook":true,"JunkFilter":true,"MTASTS":true,"MTASTSCheckResult":true,"MTASTSRecord":true,"MX":true,"MXCheckResult":true,"Modifier":true,"Msg":true,"MsgResult":true,"MsgRetired":true,"OutgoingWebhook":true,"Pair":true,"Policy":true,"PolicyEvaluated":true,"PolicyOverrideReason":true,"PolicyPublished":true,"PolicyRecord":true,"Record":true,"Report":true,"ReportMetadata":true,"ReportRecord":true,"Result":true,"ResultPolicy":true,"RetiredFilter":true,"RetiredSort":true,"Reverse":true,"Route":true,"Row":true,"Ruleset":true,"SMTPAuth":true,"SPFAuthResult":true,"SPFCheckResult":true,"SPFRecord":true,"SRV":true,"SRVConfCheckResult":true,"STSMX":true,"Selector":true,"Sort":true,"SubjectPass":true,"Summary":true,"SuppressAddress":true,"TLSCheckResult":true,"TLSRPT":true,"TLSRPTCheckResult":true,"TLSRPTDateRange":true,"TLSRPTRecord":true,"TLSRPTSummary":true,"TLSRPTSuppressAddress":true,"TLSReportRecord":true,"TLSResult":true,"Transport":true,"TransportDirect":true,"TransportSMTP":true,"TransportSocks":true,"URI":true,"WebForward":true,"WebHandler":true,"WebInternal":true,"WebRedirect":true,"WebStatic":true,"WebserverConfig":true} +export const structTypes: {[typename: string]: boolean} = {"Account":true,"Address":true,"AddressAlias":true,"Alias":true,"AliasAddress":true,"AuthResults":true,"AutoconfCheckResult":true,"AutodiscoverCheckResult":true,"AutodiscoverSRV":true,"AutomaticJunkFlags":true,"Canonicalization":true,"CheckResult":true,"ClientConfigs":true,"ClientConfigsEntry":true,"ConfigDomain":true,"DANECheckResult":true,"DKIM":true,"DKIMAuthResult":true,"DKIMCheckResult":true,"DKIMRecord":true,"DMARC":true,"DMARCCheckResult":true,"DMARCRecord":true,"DMARCSummary":true,"DNSSECResult":true,"DateRange":true,"Destination":true,"Directive":true,"Domain":true,"DomainFeedback":true,"Dynamic":true,"Evaluation":true,"EvaluationStat":true,"Extension":true,"FailureDetails":true,"Filter":true,"HoldRule":true,"Hook":true,"HookFilter":true,"HookResult":true,"HookRetired":true,"HookRetiredFilter":true,"HookRetiredSort":true,"HookSort":true,"IPDomain":true,"IPRevCheckResult":true,"Identifiers":true,"IncomingWebhook":true,"JunkFilter":true,"MTASTS":true,"MTASTSCheckResult":true,"MTASTSRecord":true,"MX":true,"MXCheckResult":true,"Modifier":true,"Msg":true,"MsgResult":true,"MsgRetired":true,"OutgoingWebhook":true,"Pair":true,"Policy":true,"PolicyEvaluated":true,"PolicyOverrideReason":true,"PolicyPublished":true,"PolicyRecord":true,"Record":true,"Report":true,"ReportMetadata":true,"ReportRecord":true,"Result":true,"ResultPolicy":true,"RetiredFilter":true,"RetiredSort":true,"Reverse":true,"Route":true,"Row":true,"Ruleset":true,"SMTPAuth":true,"SPFAuthResult":true,"SPFCheckResult":true,"SPFRecord":true,"SRV":true,"SRVConfCheckResult":true,"STSMX":true,"Selector":true,"Sort":true,"SubjectPass":true,"Summary":true,"SuppressAddress":true,"TLSCheckResult":true,"TLSPublicKey":true,"TLSRPT":true,"TLSRPTCheckResult":true,"TLSRPTDateRange":true,"TLSRPTRecord":true,"TLSRPTSummary":true,"TLSRPTSuppressAddress":true,"TLSReportRecord":true,"TLSResult":true,"Transport":true,"TransportDirect":true,"TransportSMTP":true,"TransportSocks":true,"URI":true,"WebForward":true,"WebHandler":true,"WebInternal":true,"WebRedirect":true,"WebStatic":true,"WebserverConfig":true} export const stringsTypes: {[typename: string]: boolean} = {"Align":true,"CSRFToken":true,"DMARCPolicy":true,"IP":true,"Localpart":true,"Mode":true,"RUA":true} export const intsTypes: {[typename: string]: boolean} = {} export const types: TypenameMap = { @@ -1209,6 +1222,7 @@ export const types: TypenameMap = { "TLSResult": {"Name":"TLSResult","Docs":"","Fields":[{"Name":"ID","Docs":"","Typewords":["int64"]},{"Name":"PolicyDomain","Docs":"","Typewords":["string"]},{"Name":"DayUTC","Docs":"","Typewords":["string"]},{"Name":"RecipientDomain","Docs":"","Typewords":["string"]},{"Name":"Created","Docs":"","Typewords":["timestamp"]},{"Name":"Updated","Docs":"","Typewords":["timestamp"]},{"Name":"IsHost","Docs":"","Typewords":["bool"]},{"Name":"SendReport","Docs":"","Typewords":["bool"]},{"Name":"SentToRecipientDomain","Docs":"","Typewords":["bool"]},{"Name":"RecipientDomainReportingAddresses","Docs":"","Typewords":["[]","string"]},{"Name":"SentToPolicyDomain","Docs":"","Typewords":["bool"]},{"Name":"Results","Docs":"","Typewords":["[]","Result"]}]}, "TLSRPTSuppressAddress": {"Name":"TLSRPTSuppressAddress","Docs":"","Fields":[{"Name":"ID","Docs":"","Typewords":["int64"]},{"Name":"Inserted","Docs":"","Typewords":["timestamp"]},{"Name":"ReportingAddress","Docs":"","Typewords":["string"]},{"Name":"Until","Docs":"","Typewords":["timestamp"]},{"Name":"Comment","Docs":"","Typewords":["string"]}]}, "Dynamic": {"Name":"Dynamic","Docs":"","Fields":[{"Name":"Domains","Docs":"","Typewords":["{}","ConfigDomain"]},{"Name":"Accounts","Docs":"","Typewords":["{}","Account"]},{"Name":"WebDomainRedirects","Docs":"","Typewords":["{}","string"]},{"Name":"WebHandlers","Docs":"","Typewords":["[]","WebHandler"]},{"Name":"Routes","Docs":"","Typewords":["[]","Route"]},{"Name":"MonitorDNSBLs","Docs":"","Typewords":["[]","string"]},{"Name":"MonitorDNSBLZones","Docs":"","Typewords":["[]","Domain"]}]}, + "TLSPublicKey": {"Name":"TLSPublicKey","Docs":"","Fields":[{"Name":"Fingerprint","Docs":"","Typewords":["string"]},{"Name":"Created","Docs":"","Typewords":["timestamp"]},{"Name":"Type","Docs":"","Typewords":["string"]},{"Name":"Name","Docs":"","Typewords":["string"]},{"Name":"NoIMAPPreauth","Docs":"","Typewords":["bool"]},{"Name":"CertDER","Docs":"","Typewords":["nullable","string"]},{"Name":"Account","Docs":"","Typewords":["string"]},{"Name":"LoginAddress","Docs":"","Typewords":["string"]}]}, "CSRFToken": {"Name":"CSRFToken","Docs":"","Values":null}, "DMARCPolicy": {"Name":"DMARCPolicy","Docs":"","Values":[{"Name":"PolicyEmpty","Value":"","Docs":""},{"Name":"PolicyNone","Value":"none","Docs":""},{"Name":"PolicyQuarantine","Value":"quarantine","Docs":""},{"Name":"PolicyReject","Value":"reject","Docs":""}]}, "Align": {"Name":"Align","Docs":"","Values":[{"Name":"AlignStrict","Value":"s","Docs":""},{"Name":"AlignRelaxed","Value":"r","Docs":""}]}, @@ -1328,6 +1342,7 @@ export const parser = { TLSResult: (v: any) => parse("TLSResult", v) as TLSResult, TLSRPTSuppressAddress: (v: any) => parse("TLSRPTSuppressAddress", v) as TLSRPTSuppressAddress, Dynamic: (v: any) => parse("Dynamic", v) as Dynamic, + TLSPublicKey: (v: any) => parse("TLSPublicKey", v) as TLSPublicKey, CSRFToken: (v: any) => parse("CSRFToken", v) as CSRFToken, DMARCPolicy: (v: any) => parse("DMARCPolicy", v) as DMARCPolicy, Align: (v: any) => parse("Align", v) as Align, @@ -2235,6 +2250,14 @@ export class Client { const params: any[] = [aliaslp, domainName, addresses] return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void } + + async TLSPublicKeys(accountOpt: string): Promise { + const fn: string = "TLSPublicKeys" + const paramTypes: string[][] = [["string"]] + const returnTypes: string[][] = [["[]","TLSPublicKey"]] + const params: any[] = [accountOpt] + return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as TLSPublicKey[] | null + } } export const defaultBaseURL = (function() { From 42793834f8b6112a40e0ee9508c40c6e1798d23d Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Fri, 6 Dec 2024 14:19:39 +0100 Subject: [PATCH 15/20] add Content-Disposition and Filename to the payload of incoming webhooks for each message part. The ContentDisposition value is the base value without header key/value parameters. the Filename field is the likely filename of the part. the different email clients encode filenames differently. there is a standard mime mechanism from rfc 2231. and there is the q/b-word-encoding from rfc 2047. instead of letting users of the webhook api deal with those differences, we provide just the parsed filename. for issue #258 by morki, thanks for reporting! --- message/part.go | 64 ++++++++++++++++++++++++++++++++++++++++ queue/hook.go | 7 ++++- queue/hook_test.go | 5 +++- webaccount/account.js | 4 ++- webaccount/account.ts | 2 ++ webaccount/api.json | 14 +++++++++ webaccount/api.ts | 4 ++- webapi/doc.go | 2 ++ webapisrv/server.go | 5 +++- webapisrv/server_test.go | 4 ++- webhook/webhook.go | 42 ++++++++++++++++++-------- webmail/api.go | 7 ++++- webmail/message.go | 26 +++++++--------- webmail/view.go | 12 ++++++-- webmail/webmail.go | 24 +++++++-------- 15 files changed, 170 insertions(+), 52 deletions(-) diff --git a/message/part.go b/message/part.go index 4d158d737..bf1b8b7e3 100644 --- a/message/part.go +++ b/message/part.go @@ -598,6 +598,70 @@ func (p *Part) IsDSN() bool { (p.Parts[1].MediaSubType == "DELIVERY-STATUS" || p.Parts[1].MediaSubType == "GLOBAL-DELIVERY-STATUS") } +var ErrParamEncoding = errors.New("bad header parameter encoding") + +// DispositionFilename tries to parse the disposition header and the "filename" +// parameter. If the filename parameter is absent or can't be parsed, the "name" +// parameter from the Content-Type header is used for the filename. The returned +// filename is decoded according to RFC 2231 or RFC 2047. This is a best-effort +// attempt to find a filename for a part. If no Content-Disposition header, or +// filename was found, empty values without error are returned. +// +// If the returned error is an ErrParamEncoding, it can be treated as a diagnostic +// and a filename may still be returned. +func (p *Part) DispositionFilename() (disposition string, filename string, err error) { + h, err := p.Header() + if err != nil { + return "", "", fmt.Errorf("parsing header: %v", err) + } + var disp string + var params map[string]string + cd := h.Get("Content-Disposition") + if cd != "" { + disp, params, err = mime.ParseMediaType(cd) + } + if err != nil { + return "", "", fmt.Errorf("%w: parsing disposition header: %v", ErrParamEncoding, err) + } + filename, err = tryDecodeParam(params["filename"]) + if filename == "" { + s, err2 := tryDecodeParam(p.ContentTypeParams["name"]) + filename = s + if err == nil { + err = err2 + } + } + return disp, filename, err +} + +// Attempt q/b-word-decode name, coming from Content-Type "name" field or +// Content-Disposition "filename" field. +// +// RFC 2231 specifies an encoding for non-ascii values in mime header parameters. But +// it appears common practice to instead just q/b-word encode the values. +// Thunderbird and gmail.com do this for the Content-Type "name" parameter. +// gmail.com also does that for the Content-Disposition "filename" parameter, where +// Thunderbird uses the RFC 2231-defined encoding. Go's mime.ParseMediaType parses +// the mechanism specified in RFC 2231 only. The value for "name" we get here would +// already be decoded properly for standards-compliant headers, like +// "filename*0*=UTF-8”%...; filename*1*=%.... We'll look for Q/B-word encoding +// markers ("=?"-prefix or "?="-suffix) and try to decode if present. This would +// only cause trouble for filenames having this prefix/suffix. +func tryDecodeParam(name string) (string, error) { + if name == "" || !strings.HasPrefix(name, "=?") && !strings.HasSuffix(name, "?=") { + return name, nil + } + // todo: find where this is allowed. it seems quite common. perhaps we should remove the pedantic check? + if Pedantic { + return name, fmt.Errorf("%w: attachment contains rfc2047 q/b-word-encoded mime parameter instead of rfc2231-encoded", ErrParamEncoding) + } + s, err := wordDecoder.DecodeHeader(name) + if err != nil { + return name, fmt.Errorf("%w: q/b-word decoding mime parameter: %v", ErrParamEncoding, err) + } + return s, nil +} + // Reader returns a reader for the decoded body content. func (p *Part) Reader() io.Reader { return p.bodyReader(p.RawReader()) diff --git a/queue/hook.go b/queue/hook.go index ec3728b70..700bec49b 100644 --- a/queue/hook.go +++ b/queue/hook.go @@ -796,13 +796,18 @@ func Incoming(ctx context.Context, log mlog.Log, acc *store.Account, messageID s log.Debug("composing webhook for incoming message") + structure, err := webhook.PartStructure(log, &part) + if err != nil { + return fmt.Errorf("parsing part structure: %v", err) + } + isIncoming = true var rcptTo string if m.RcptToDomain != "" { rcptTo = m.RcptToLocalpart.String() + "@" + m.RcptToDomain } in := webhook.Incoming{ - Structure: webhook.PartStructure(&part), + Structure: structure, Meta: webhook.IncomingMeta{ MsgID: m.ID, MailFrom: m.MailFrom, diff --git a/queue/hook_test.go b/queue/hook_test.go index c5d7c36f3..75cebd1da 100644 --- a/queue/hook_test.go +++ b/queue/hook_test.go @@ -82,6 +82,9 @@ func TestHookIncoming(t *testing.T) { tcheck(t, err, "decode incoming webhook") in.Meta.Received = in.Meta.Received.Local() // For TZ UTC. + structure, err := webhook.PartStructure(pkglog, &part) + tcheck(t, err, "part structure") + expIncoming := webhook.Incoming{ From: []webhook.NameAddress{{Address: "mjl@mox.example"}}, To: []webhook.NameAddress{{Address: "mjl@mox.example"}}, @@ -92,7 +95,7 @@ func TestHookIncoming(t *testing.T) { Subject: "test", Text: "test email\n", - Structure: webhook.PartStructure(&part), + Structure: structure, Meta: webhook.IncomingMeta{ MsgID: m.ID, MailFrom: m.MailFrom, diff --git a/webaccount/account.js b/webaccount/account.js index c68952ceb..d43285b9c 100644 --- a/webaccount/account.js +++ b/webaccount/account.js @@ -278,7 +278,7 @@ var api; "Outgoing": { "Name": "Outgoing", "Docs": "", "Fields": [{ "Name": "Version", "Docs": "", "Typewords": ["int32"] }, { "Name": "Event", "Docs": "", "Typewords": ["OutgoingEvent"] }, { "Name": "DSN", "Docs": "", "Typewords": ["bool"] }, { "Name": "Suppressing", "Docs": "", "Typewords": ["bool"] }, { "Name": "QueueMsgID", "Docs": "", "Typewords": ["int64"] }, { "Name": "FromID", "Docs": "", "Typewords": ["string"] }, { "Name": "MessageID", "Docs": "", "Typewords": ["string"] }, { "Name": "Subject", "Docs": "", "Typewords": ["string"] }, { "Name": "WebhookQueued", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "SMTPCode", "Docs": "", "Typewords": ["int32"] }, { "Name": "SMTPEnhancedCode", "Docs": "", "Typewords": ["string"] }, { "Name": "Error", "Docs": "", "Typewords": ["string"] }, { "Name": "Extra", "Docs": "", "Typewords": ["{}", "string"] }] }, "Incoming": { "Name": "Incoming", "Docs": "", "Fields": [{ "Name": "Version", "Docs": "", "Typewords": ["int32"] }, { "Name": "From", "Docs": "", "Typewords": ["[]", "NameAddress"] }, { "Name": "To", "Docs": "", "Typewords": ["[]", "NameAddress"] }, { "Name": "CC", "Docs": "", "Typewords": ["[]", "NameAddress"] }, { "Name": "BCC", "Docs": "", "Typewords": ["[]", "NameAddress"] }, { "Name": "ReplyTo", "Docs": "", "Typewords": ["[]", "NameAddress"] }, { "Name": "Subject", "Docs": "", "Typewords": ["string"] }, { "Name": "MessageID", "Docs": "", "Typewords": ["string"] }, { "Name": "InReplyTo", "Docs": "", "Typewords": ["string"] }, { "Name": "References", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Date", "Docs": "", "Typewords": ["nullable", "timestamp"] }, { "Name": "Text", "Docs": "", "Typewords": ["string"] }, { "Name": "HTML", "Docs": "", "Typewords": ["string"] }, { "Name": "Structure", "Docs": "", "Typewords": ["Structure"] }, { "Name": "Meta", "Docs": "", "Typewords": ["IncomingMeta"] }] }, "NameAddress": { "Name": "NameAddress", "Docs": "", "Fields": [{ "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "Address", "Docs": "", "Typewords": ["string"] }] }, - "Structure": { "Name": "Structure", "Docs": "", "Fields": [{ "Name": "ContentType", "Docs": "", "Typewords": ["string"] }, { "Name": "ContentTypeParams", "Docs": "", "Typewords": ["{}", "string"] }, { "Name": "ContentID", "Docs": "", "Typewords": ["string"] }, { "Name": "DecodedSize", "Docs": "", "Typewords": ["int64"] }, { "Name": "Parts", "Docs": "", "Typewords": ["[]", "Structure"] }] }, + "Structure": { "Name": "Structure", "Docs": "", "Fields": [{ "Name": "ContentType", "Docs": "", "Typewords": ["string"] }, { "Name": "ContentTypeParams", "Docs": "", "Typewords": ["{}", "string"] }, { "Name": "ContentID", "Docs": "", "Typewords": ["string"] }, { "Name": "ContentDisposition", "Docs": "", "Typewords": ["string"] }, { "Name": "Filename", "Docs": "", "Typewords": ["string"] }, { "Name": "DecodedSize", "Docs": "", "Typewords": ["int64"] }, { "Name": "Parts", "Docs": "", "Typewords": ["[]", "Structure"] }] }, "IncomingMeta": { "Name": "IncomingMeta", "Docs": "", "Fields": [{ "Name": "MsgID", "Docs": "", "Typewords": ["int64"] }, { "Name": "MailFrom", "Docs": "", "Typewords": ["string"] }, { "Name": "MailFromValidated", "Docs": "", "Typewords": ["bool"] }, { "Name": "MsgFromValidated", "Docs": "", "Typewords": ["bool"] }, { "Name": "RcptTo", "Docs": "", "Typewords": ["string"] }, { "Name": "DKIMVerifiedDomains", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "RemoteIP", "Docs": "", "Typewords": ["string"] }, { "Name": "Received", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "MailboxName", "Docs": "", "Typewords": ["string"] }, { "Name": "Automated", "Docs": "", "Typewords": ["bool"] }] }, "TLSPublicKey": { "Name": "TLSPublicKey", "Docs": "", "Fields": [{ "Name": "Fingerprint", "Docs": "", "Typewords": ["string"] }, { "Name": "Created", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "Type", "Docs": "", "Typewords": ["string"] }, { "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "NoIMAPPreauth", "Docs": "", "Typewords": ["bool"] }, { "Name": "CertDER", "Docs": "", "Typewords": ["nullable", "string"] }, { "Name": "Account", "Docs": "", "Typewords": ["string"] }, { "Name": "LoginAddress", "Docs": "", "Typewords": ["string"] }] }, "CSRFToken": { "Name": "CSRFToken", "Docs": "", "Values": null }, @@ -1390,6 +1390,8 @@ const index = async () => { ContentType: 'text/plain', ContentTypeParams: { charset: 'utf-8' }, ContentID: '', + ContentDisposition: '', + Filename: '', DecodedSize: 8, Parts: [], }, diff --git a/webaccount/account.ts b/webaccount/account.ts index 5a0df40bb..8687e584e 100644 --- a/webaccount/account.ts +++ b/webaccount/account.ts @@ -669,6 +669,8 @@ const index = async () => { ContentType: 'text/plain', ContentTypeParams: {charset: 'utf-8'}, ContentID: '', + ContentDisposition: '', + Filename: '', DecodedSize: 8, Parts: [], }, diff --git a/webaccount/api.json b/webaccount/api.json index cbae88d5f..f5d4bd7ae 100644 --- a/webaccount/api.json +++ b/webaccount/api.json @@ -1495,6 +1495,20 @@ "string" ] }, + { + "Name": "ContentDisposition", + "Docs": "Lower-case value, e.g. \"attachment\", \"inline\" or empty when absent. Without the key/value header parameters.", + "Typewords": [ + "string" + ] + }, + { + "Name": "Filename", + "Docs": "Filename for this part, based on \"filename\" parameter from Content-Disposition, or \"name\" from Content-Type after decoding.", + "Typewords": [ + "string" + ] + }, { "Name": "DecodedSize", "Docs": "Size of content after decoding content-transfer-encoding. For text and HTML parts, this can be larger than the data returned since this size includes \\r\\n line endings.", diff --git a/webaccount/api.ts b/webaccount/api.ts index b90af74ff..c8e3236c1 100644 --- a/webaccount/api.ts +++ b/webaccount/api.ts @@ -187,6 +187,8 @@ export interface Structure { ContentType: string // Lower case, e.g. text/plain. ContentTypeParams?: { [key: string]: string } // Lower case keys, original case values, e.g. {"charset": "UTF-8"}. ContentID: string // Can be empty. Otherwise, should be a value wrapped in <>'s. For use in HTML, referenced as URI `cid:...`. + ContentDisposition: string // Lower-case value, e.g. "attachment", "inline" or empty when absent. Without the key/value header parameters. + Filename: string // Filename for this part, based on "filename" parameter from Content-Disposition, or "name" from Content-Type after decoding. DecodedSize: number // Size of content after decoding content-transfer-encoding. For text and HTML parts, this can be larger than the data returned since this size includes \r\n line endings. Parts?: Structure[] | null // Subparts of a multipart message, possibly recursive. } @@ -274,7 +276,7 @@ export const types: TypenameMap = { "Outgoing": {"Name":"Outgoing","Docs":"","Fields":[{"Name":"Version","Docs":"","Typewords":["int32"]},{"Name":"Event","Docs":"","Typewords":["OutgoingEvent"]},{"Name":"DSN","Docs":"","Typewords":["bool"]},{"Name":"Suppressing","Docs":"","Typewords":["bool"]},{"Name":"QueueMsgID","Docs":"","Typewords":["int64"]},{"Name":"FromID","Docs":"","Typewords":["string"]},{"Name":"MessageID","Docs":"","Typewords":["string"]},{"Name":"Subject","Docs":"","Typewords":["string"]},{"Name":"WebhookQueued","Docs":"","Typewords":["timestamp"]},{"Name":"SMTPCode","Docs":"","Typewords":["int32"]},{"Name":"SMTPEnhancedCode","Docs":"","Typewords":["string"]},{"Name":"Error","Docs":"","Typewords":["string"]},{"Name":"Extra","Docs":"","Typewords":["{}","string"]}]}, "Incoming": {"Name":"Incoming","Docs":"","Fields":[{"Name":"Version","Docs":"","Typewords":["int32"]},{"Name":"From","Docs":"","Typewords":["[]","NameAddress"]},{"Name":"To","Docs":"","Typewords":["[]","NameAddress"]},{"Name":"CC","Docs":"","Typewords":["[]","NameAddress"]},{"Name":"BCC","Docs":"","Typewords":["[]","NameAddress"]},{"Name":"ReplyTo","Docs":"","Typewords":["[]","NameAddress"]},{"Name":"Subject","Docs":"","Typewords":["string"]},{"Name":"MessageID","Docs":"","Typewords":["string"]},{"Name":"InReplyTo","Docs":"","Typewords":["string"]},{"Name":"References","Docs":"","Typewords":["[]","string"]},{"Name":"Date","Docs":"","Typewords":["nullable","timestamp"]},{"Name":"Text","Docs":"","Typewords":["string"]},{"Name":"HTML","Docs":"","Typewords":["string"]},{"Name":"Structure","Docs":"","Typewords":["Structure"]},{"Name":"Meta","Docs":"","Typewords":["IncomingMeta"]}]}, "NameAddress": {"Name":"NameAddress","Docs":"","Fields":[{"Name":"Name","Docs":"","Typewords":["string"]},{"Name":"Address","Docs":"","Typewords":["string"]}]}, - "Structure": {"Name":"Structure","Docs":"","Fields":[{"Name":"ContentType","Docs":"","Typewords":["string"]},{"Name":"ContentTypeParams","Docs":"","Typewords":["{}","string"]},{"Name":"ContentID","Docs":"","Typewords":["string"]},{"Name":"DecodedSize","Docs":"","Typewords":["int64"]},{"Name":"Parts","Docs":"","Typewords":["[]","Structure"]}]}, + "Structure": {"Name":"Structure","Docs":"","Fields":[{"Name":"ContentType","Docs":"","Typewords":["string"]},{"Name":"ContentTypeParams","Docs":"","Typewords":["{}","string"]},{"Name":"ContentID","Docs":"","Typewords":["string"]},{"Name":"ContentDisposition","Docs":"","Typewords":["string"]},{"Name":"Filename","Docs":"","Typewords":["string"]},{"Name":"DecodedSize","Docs":"","Typewords":["int64"]},{"Name":"Parts","Docs":"","Typewords":["[]","Structure"]}]}, "IncomingMeta": {"Name":"IncomingMeta","Docs":"","Fields":[{"Name":"MsgID","Docs":"","Typewords":["int64"]},{"Name":"MailFrom","Docs":"","Typewords":["string"]},{"Name":"MailFromValidated","Docs":"","Typewords":["bool"]},{"Name":"MsgFromValidated","Docs":"","Typewords":["bool"]},{"Name":"RcptTo","Docs":"","Typewords":["string"]},{"Name":"DKIMVerifiedDomains","Docs":"","Typewords":["[]","string"]},{"Name":"RemoteIP","Docs":"","Typewords":["string"]},{"Name":"Received","Docs":"","Typewords":["timestamp"]},{"Name":"MailboxName","Docs":"","Typewords":["string"]},{"Name":"Automated","Docs":"","Typewords":["bool"]}]}, "TLSPublicKey": {"Name":"TLSPublicKey","Docs":"","Fields":[{"Name":"Fingerprint","Docs":"","Typewords":["string"]},{"Name":"Created","Docs":"","Typewords":["timestamp"]},{"Name":"Type","Docs":"","Typewords":["string"]},{"Name":"Name","Docs":"","Typewords":["string"]},{"Name":"NoIMAPPreauth","Docs":"","Typewords":["bool"]},{"Name":"CertDER","Docs":"","Typewords":["nullable","string"]},{"Name":"Account","Docs":"","Typewords":["string"]},{"Name":"LoginAddress","Docs":"","Typewords":["string"]}]}, "CSRFToken": {"Name":"CSRFToken","Docs":"","Values":null}, diff --git a/webapi/doc.go b/webapi/doc.go index 73b3e7e4c..93e2e2ab7 100644 --- a/webapi/doc.go +++ b/webapi/doc.go @@ -347,6 +347,8 @@ Example JSON body for webhooks for incoming delivery of basic message: "charset": "utf-8" }, "ContentID": "", + "ContentDisposition": "", + "Filename": "", "DecodedSize": 17, "Parts": [] }, diff --git a/webapisrv/server.go b/webapisrv/server.go index 84305da04..8505ba9c5 100644 --- a/webapisrv/server.go +++ b/webapisrv/server.go @@ -1263,9 +1263,12 @@ func (s server) MessageGet(ctx context.Context, req webapi.MessageGetRequest) (r MailboxName: mb.Name, } + structure, err := webhook.PartStructure(log, &p) + xcheckf(err, "parsing structure") + result := webapi.MessageGetResult{ Message: msg, - Structure: webhook.PartStructure(&p), + Structure: structure, Meta: meta, } return result, nil diff --git a/webapisrv/server_test.go b/webapisrv/server_test.go index 327109862..995accb32 100644 --- a/webapisrv/server_test.go +++ b/webapisrv/server_test.go @@ -418,7 +418,9 @@ func TestServer(t *testing.T) { tcheckf(t, err, "reading raw message") part, err := message.EnsurePart(log.Logger, true, bytes.NewReader(b.Bytes()), int64(b.Len())) tcheckf(t, err, "parsing raw message") - tcompare(t, webhook.PartStructure(&part), msgRes.Structure) + structure, err := webhook.PartStructure(log, &part) + tcheckf(t, err, "part structure") + tcompare(t, structure, msgRes.Structure) _, err = client.MessageRawGet(ctxbg, webapi.MessageRawGetRequest{MsgID: 1 + 999}) terrcode(t, err, "messageNotFound") diff --git a/webhook/webhook.go b/webhook/webhook.go index 386cf23c6..9582c5543 100644 --- a/webhook/webhook.go +++ b/webhook/webhook.go @@ -8,10 +8,12 @@ package webhook import ( + "errors" "strings" "time" "github.com/mjl-/mox/message" + "github.com/mjl-/mox/mlog" ) // OutgoingEvent is an activity for an outgoing delivery. Either generated by the @@ -135,29 +137,43 @@ type NameAddress struct { } type Structure struct { - ContentType string // Lower case, e.g. text/plain. - ContentTypeParams map[string]string // Lower case keys, original case values, e.g. {"charset": "UTF-8"}. - ContentID string // Can be empty. Otherwise, should be a value wrapped in <>'s. For use in HTML, referenced as URI `cid:...`. - DecodedSize int64 // Size of content after decoding content-transfer-encoding. For text and HTML parts, this can be larger than the data returned since this size includes \r\n line endings. - Parts []Structure // Subparts of a multipart message, possibly recursive. + ContentType string // Lower case, e.g. text/plain. + ContentTypeParams map[string]string // Lower case keys, original case values, e.g. {"charset": "UTF-8"}. + ContentID string // Can be empty. Otherwise, should be a value wrapped in <>'s. For use in HTML, referenced as URI `cid:...`. + ContentDisposition string // Lower-case value, e.g. "attachment", "inline" or empty when absent. Without the key/value header parameters. + Filename string // Filename for this part, based on "filename" parameter from Content-Disposition, or "name" from Content-Type after decoding. + DecodedSize int64 // Size of content after decoding content-transfer-encoding. For text and HTML parts, this can be larger than the data returned since this size includes \r\n line endings. + Parts []Structure // Subparts of a multipart message, possibly recursive. } // PartStructure returns a Structure for a parsed message part. -func PartStructure(p *message.Part) Structure { +func PartStructure(log mlog.Log, p *message.Part) (Structure, error) { parts := make([]Structure, len(p.Parts)) for i := range p.Parts { - parts[i] = PartStructure(&p.Parts[i]) + var err error + parts[i], err = PartStructure(log, &p.Parts[i]) + if err != nil && !errors.Is(err, message.ErrParamEncoding) { + return Structure{}, err + } + } + disp, filename, err := p.DispositionFilename() + if err != nil && errors.Is(err, message.ErrParamEncoding) { + log.Debugx("parsing disposition/filename", err) + } else if err != nil { + return Structure{}, err } s := Structure{ - ContentType: strings.ToLower(p.MediaType + "/" + p.MediaSubType), - ContentTypeParams: p.ContentTypeParams, - ContentID: p.ContentID, - DecodedSize: p.DecodedSize, - Parts: parts, + ContentType: strings.ToLower(p.MediaType + "/" + p.MediaSubType), + ContentTypeParams: p.ContentTypeParams, + ContentID: p.ContentID, + ContentDisposition: strings.ToLower(disp), + Filename: filename, + DecodedSize: p.DecodedSize, + Parts: parts, } // Replace nil map with empty map, for easier to use JSON. if s.ContentTypeParams == nil { s.ContentTypeParams = map[string]string{} } - return s + return s, nil } diff --git a/webmail/api.go b/webmail/api.go index 1800d4c10..3bfa30956 100644 --- a/webmail/api.go +++ b/webmail/api.go @@ -904,7 +904,12 @@ func (w Webmail) MessageSubmit(ctx context.Context, m SubmitMessage) { ap = ap.Parts[xp] } - filename := tryDecodeParam(log, ap.ContentTypeParams["name"]) + _, filename, err := ap.DispositionFilename() + if err != nil && errors.Is(err, message.ErrParamEncoding) { + log.Debugx("parsing disposition/filename", err) + } else { + xcheckf(ctx, err, "reading disposition") + } if filename == "" { filename = "unnamed.bin" } diff --git a/webmail/message.go b/webmail/message.go index 74d609f11..26bf987aa 100644 --- a/webmail/message.go +++ b/webmail/message.go @@ -2,6 +2,7 @@ package webmail import ( "bufio" + "errors" "fmt" "io" "log/slog" @@ -275,21 +276,16 @@ func parsedMessage(log mlog.Log, m store.Message, state *msgState, full, msgitem case "TEXT/PLAIN", "/": // Don't include if Content-Disposition attachment. if full || msgitem { - // todo: should have this, and perhaps all content-* headers, preparsed in message.Part? - h, err := p.Header() - log.Check(err, "parsing attachment headers", slog.Int64("msgid", m.ID)) - cp := h.Get("Content-Disposition") - if cp != "" { - disp, params, err := mime.ParseMediaType(cp) - log.Check(err, "parsing content-disposition", slog.String("cp", cp)) - if strings.EqualFold(disp, "attachment") { - name := tryDecodeParam(log, p.ContentTypeParams["name"]) - if name == "" { - name = tryDecodeParam(log, params["filename"]) - } - addAttachment(Attachment{path, name, p}, parentMixed) - return - } + disp, name, err := p.DispositionFilename() + if err != nil && errors.Is(err, message.ErrParamEncoding) { + log.Debugx("parsing disposition/filename", err) + } else if err != nil { + rerr = fmt.Errorf("reading disposition/filename: %v", err) + return + } + if strings.EqualFold(disp, "attachment") { + addAttachment(Attachment{path, name, p}, parentMixed) + return } } diff --git a/webmail/view.go b/webmail/view.go index 46276c644..cc4b2f438 100644 --- a/webmail/view.go +++ b/webmail/view.go @@ -1829,11 +1829,17 @@ func attachmentTypes(log mlog.Log, m store.Message, state *msgState) (map[Attach mt := strings.ToLower(a.Part.MediaType + "/" + a.Part.MediaSubType) if t, ok := attachmentMimetypes[mt]; ok { types[t] = true - } else if ext := filepath.Ext(tryDecodeParam(log, a.Part.ContentTypeParams["name"])); ext != "" { + continue + } + _, filename, err := a.Part.DispositionFilename() + if err != nil && errors.Is(err, message.ErrParamEncoding) { + log.Debugx("parsing disposition/filename", err) + } else if err != nil { + return nil, fmt.Errorf("reading disposition/filename: %v", err) + } + if ext := filepath.Ext(filename); ext != "" { if t, ok := attachmentExtensions[strings.ToLower(ext)]; ok { types[t] = true - } else { - continue } } } diff --git a/webmail/webmail.go b/webmail/webmail.go index 8cfa3d7ba..4319f10ad 100644 --- a/webmail/webmail.go +++ b/webmail/webmail.go @@ -521,13 +521,11 @@ func handle(apiHandler http.Handler, isForwarded bool, accountPath string, w htt names := map[string]bool{} for _, a := range mi.Attachments { ap := a.Part - name := tryDecodeParam(log, ap.ContentTypeParams["name"]) - if name == "" { - // We don't check errors, this is all best-effort. - h, _ := ap.Header() - disposition := h.Get("Content-Disposition") - _, params, _ := mime.ParseMediaType(disposition) - name = tryDecodeParam(log, params["filename"]) + _, name, err := ap.DispositionFilename() + if err != nil && errors.Is(err, message.ErrParamEncoding) { + log.Debugx("parsing disposition header for filename", err) + } else { + xcheckf(ctx, err, "reading disposition header") } if name != "" { name = filepath.Base(name) @@ -816,13 +814,11 @@ func handle(apiHandler http.Handler, isForwarded bool, accountPath string, w htt h.Set("Content-Type", ct) h.Set("Cache-Control", "no-store, max-age=0") if t[1] == "download" { - name := tryDecodeParam(log, ap.ContentTypeParams["name"]) - if name == "" { - // We don't check errors, this is all best-effort. - h, _ := ap.Header() - disposition := h.Get("Content-Disposition") - _, params, _ := mime.ParseMediaType(disposition) - name = tryDecodeParam(log, params["filename"]) + _, name, err := ap.DispositionFilename() + if err != nil && errors.Is(err, message.ErrParamEncoding) { + log.Debugx("parsing disposition/filename", err) + } else { + xcheckf(ctx, err, "reading disposition/filename") } if name == "" { name = "attachment.bin" From e59f894a94c21347d1c3e70d72375926e00850da Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Fri, 6 Dec 2024 14:50:02 +0100 Subject: [PATCH 16/20] add an option for the smtp delivery listener to enable/disable tls session tickets the field is optional. if absent, the default behaviour is currently to disable session tickets. users can set the option if they want to try if delivery from microsoft is working again. in a future version, we can switch the default to enabling session tickets. the previous fix was to disable session tickets for all tls connections, including https. that was a bit much. for issue #237 --- autotls/autotls.go | 1 - config/config.go | 2 ++ config/doc.go | 4 ++++ mox-/config.go | 3 +-- smtpserver/server.go | 7 +++++++ 5 files changed, 14 insertions(+), 3 deletions(-) diff --git a/autotls/autotls.go b/autotls/autotls.go index 77c7b1560..4bbc22935 100644 --- a/autotls/autotls.go +++ b/autotls/autotls.go @@ -229,7 +229,6 @@ func (m *Manager) TLSConfig(fallbackHostname dns.Domain, fallbackNoSNI, fallback GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { return m.loggingGetCertificate(hello, fallbackHostname, fallbackNoSNI, fallbackUnknownSNI) }, - SessionTicketsDisabled: true, } } diff --git a/config/config.go b/config/config.go index 86acb2b31..5d36df6d5 100644 --- a/config/config.go +++ b/config/config.go @@ -158,6 +158,8 @@ type Listener struct { FirstTimeSenderDelay *time.Duration `sconf:"optional" sconf-doc:"Delay before accepting a message from a first-time sender for the destination account. Default: 15s."` + TLSSessionTicketsDisabled *bool `sconf:"optional" sconf-doc:"Override default setting for enabling TLS session tickets. Disabling session tickets may work around TLS interoperability issues."` + DNSBLZones []dns.Domain `sconf:"-"` } `sconf:"optional"` Submission struct { diff --git a/config/doc.go b/config/doc.go index a2e25711a..cccc5bbc0 100644 --- a/config/doc.go +++ b/config/doc.go @@ -262,6 +262,10 @@ See https://pkg.go.dev/github.com/mjl-/sconf for details. # account. Default: 15s. (optional) FirstTimeSenderDelay: 0s + # Override default setting for enabling TLS session tickets. Disabling session + # tickets may work around TLS interoperability issues. (optional) + TLSSessionTicketsDisabled: false + # SMTP for submitting email, e.g. by email applications. Starts out in plain text, # can be upgraded to TLS with the STARTTLS command. Prefer using Submissions which # is always a TLS connection. (optional) diff --git a/mox-/config.go b/mox-/config.go index 1abd00c72..826aa1606 100644 --- a/mox-/config.go +++ b/mox-/config.go @@ -1933,8 +1933,7 @@ func loadTLSKeyCerts(configFile, kind string, ctls *config.TLS) error { certs = append(certs, cert) } ctls.Config = &tls.Config{ - Certificates: certs, - SessionTicketsDisabled: true, + Certificates: certs, } ctls.ConfigFallback = ctls.Config return nil diff --git a/smtpserver/server.go b/smtpserver/server.go index f933a8f4d..e90f0c997 100644 --- a/smtpserver/server.go +++ b/smtpserver/server.go @@ -229,6 +229,13 @@ func Listen() { port := config.Port(listener.SMTP.Port, 25) for _, ip := range listener.IPs { firstTimeSenderDelay := durationDefault(listener.SMTP.FirstTimeSenderDelay, firstTimeSenderDelayDefault) + if tlsConfigDelivery != nil { + tlsConfigDelivery = tlsConfigDelivery.Clone() + // Default setting is currently to have session tickets disabled, to work around + // TLS interoperability issues with incoming deliveries from Microsoft. See + // https://github.com/golang/go/issues/70232. + tlsConfigDelivery.SessionTicketsDisabled = listener.SMTP.TLSSessionTicketsDisabled == nil || *listener.SMTP.TLSSessionTicketsDisabled + } listen1("smtp", name, ip, port, hostname, tlsConfigDelivery, false, false, maxMsgSize, false, listener.SMTP.RequireSTARTTLS, !listener.SMTP.NoRequireTLS, listener.SMTP.DNSBLZones, firstTimeSenderDelay) } } From 056b571fb642d814cfbb8160f9a26cb06d7d1a70 Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Fri, 6 Dec 2024 14:57:20 +0100 Subject: [PATCH 17/20] webmail: don't consume keyboard events while login form is open e.g. ctrl-l, for going to address bar to go to another site. --- webmail/webmail.js | 2 ++ webmail/webmail.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/webmail/webmail.js b/webmail/webmail.js index 4535659b0..deb358e12 100644 --- a/webmail/webmail.js +++ b/webmail/webmail.js @@ -1679,6 +1679,7 @@ let rejectsMailbox = ''; // Last known server version. For asking to reload. let lastServerVersion = ''; const login = async (reason) => { + popupOpen = true; // Prevent global key event handler from consuming keys. return new Promise((resolve, _) => { const origFocus = document.activeElement; let reasonElem; @@ -1716,6 +1717,7 @@ const login = async (reason) => { if (origFocus && origFocus instanceof HTMLElement && origFocus.parentNode) { origFocus.focus(); } + popupOpen = false; resolve(token); } catch (err) { diff --git a/webmail/webmail.ts b/webmail/webmail.ts index 3c24cdd3a..29b7d43dd 100644 --- a/webmail/webmail.ts +++ b/webmail/webmail.ts @@ -262,6 +262,7 @@ let rejectsMailbox: string = '' let lastServerVersion: string = '' const login = async (reason: string) => { + popupOpen = true // Prevent global key event handler from consuming keys. return new Promise((resolve: (v: string) => void, _) => { const origFocus = document.activeElement let reasonElem: HTMLElement @@ -308,6 +309,7 @@ const login = async (reason: string) => { if (origFocus && origFocus instanceof HTMLElement && origFocus.parentNode) { origFocus.focus() } + popupOpen = false resolve(token) } catch (err) { console.log('login error', err) From b75066815299e720a8f3d07637056b023f202805 Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Fri, 6 Dec 2024 15:07:42 +0100 Subject: [PATCH 18/20] add metrics that track how many error/warn/info logging is happening --- mlog/log.go | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/mlog/log.go b/mlog/log.go index d041c98ae..122599a0a 100644 --- a/mlog/log.go +++ b/mlog/log.go @@ -25,10 +25,34 @@ import ( "strings" "sync/atomic" "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" ) var noctx = context.Background() +var ( + metricLogInfo = promauto.NewCounter( + prometheus.CounterOpts{ + Name: "mox_logging_level_info_total", + Help: "Total number of logging events at level info.", + }, + ) + metricLogWarn = promauto.NewCounter( + prometheus.CounterOpts{ + Name: "mox_logging_level_warn_total", + Help: "Total number of logging events at level warn.", + }, + ) + metricLogError = promauto.NewCounter( + prometheus.CounterOpts{ + Name: "mox_logging_level_error_total", + Help: "Total number of logging events at level error.", + }, + ) +) + // Logfmt enabled output in logfmt, instead of output more suitable for // command-line tools. Must be set early in a program lifecycle. var Logfmt bool @@ -325,6 +349,15 @@ func (h *handler) Handle(ctx context.Context, r slog.Record) error { if !ok { return nil } + if r.Level >= LevelInfo { + if r.Level == LevelInfo { + metricLogInfo.Inc() + } else if r.Level <= LevelWarn { + metricLogWarn.Inc() + } else if r.Level <= LevelError { + metricLogError.Inc() + } + } if hideData, hideAuth := traceLevel(l, r.Level); hideData { r.Message = "..." } else if hideAuth { From ce75852b7c49bfdd4c113fcd6a432517b0f61855 Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Fri, 6 Dec 2024 15:49:22 +0100 Subject: [PATCH 19/20] add missing space in x-mox-reason that's been bothering me for a while --- smtpserver/analyze.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/smtpserver/analyze.go b/smtpserver/analyze.go index 303f3192d..01030cb86 100644 --- a/smtpserver/analyze.go +++ b/smtpserver/analyze.go @@ -601,7 +601,7 @@ func analyze(ctx context.Context, log mlog.Log, resolver dns.Resolver, d deliver s += "junk" } s += fmt.Sprintf(", spamscore %.2f, threshold %.2f%s", contentProb, threshold, thresholdRemark) - s += "(ham words: " + s += " (ham words: " for i, w := range hams { if i > 0 { s += ", " From 0a77bc59558bbb237d0a2439b60efcf213cace6c Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Fri, 6 Dec 2024 15:59:22 +0100 Subject: [PATCH 20/20] tweak doucmentation for sasl and scram --- sasl/sasl.go | 13 ++++++++++++- scram/scram.go | 7 ++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/sasl/sasl.go b/sasl/sasl.go index f49063cca..22c434846 100644 --- a/sasl/sasl.go +++ b/sasl/sasl.go @@ -1,4 +1,15 @@ -// Package SASL implements Simple Authentication and Security Layer, RFC 4422. +// Package SASL implements a client for Simple Authentication and Security Layer, RFC 4422. +// +// Supported authentication mechanisms: +// +// - EXTERNAL +// - SCRAM-SHA-256-PLUS +// - SCRAM-SHA-1-PLUS +// - SCRAM-SHA-256 +// - SCRAM-SHA-1 +// - CRAM-MD5 +// - PLAIN +// - LOGIN package sasl import ( diff --git a/scram/scram.go b/scram/scram.go index 08532f413..20dbf0220 100644 --- a/scram/scram.go +++ b/scram/scram.go @@ -1,9 +1,10 @@ -// Package scram implements the SCRAM-SHA-* SASL authentication mechanism, RFC 7677 and RFC 5802. +// Package scram implements the SCRAM-SHA-* SASL authentication mechanisms, including the PLUS variants, RFC 7677 and RFC 5802. // // SCRAM-SHA-256 and SCRAM-SHA-1 allow a client to authenticate to a server using a // password without handing plaintext password over to the server. The client also -// verifies the server knows (a derivative of) the password. Both the client and -// server side are implemented. +// verifies the server knows (a derivative of) the password. The *-PLUS variants +// bind the authentication exchange to the TLS session, preventing MitM attempts. +// Both the client and server side are implemented. package scram // todo: test with messages that contains extensions