From 4d3c4115f823272fa6069349838a4c042ddf2f78 Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Sat, 7 Dec 2024 11:51:11 +0100 Subject: [PATCH 01/12] webmail: don't bind to shortcuts ctrl-l, ctrl-u and ctrl-I ctrl-l is commonly "focus on browser address bar". ctrl-u is commonly "view source". ctrl-I (shift i) is commonly "open developer console". these keys are more useful to leave for the browser. ctrl-l and ctrl-u (moving to a message without opening it) can still be had by using also pressing shift. the previous ctrl-shift-i (show all headers) is now just ctrl-i. this has been requested in the past on irc/matrix (i forgot who). --- webmail/webmail.js | 16 +++++++++++++--- webmail/webmail.ts | 23 ++++++++++++++++++++--- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/webmail/webmail.js b/webmail/webmail.js index deb358e..516c6b2 100644 --- a/webmail/webmail.js +++ b/webmail/webmail.js @@ -2477,7 +2477,7 @@ const cmdHelp = async () => { ['←', 'collapse'], ['→', 'expand'], ['b', 'show more actions'], - ].map(t => dom.tr(dom.td(t[0]), dom.td(t[1]))), dom.tr(dom.td(attr.colspan('2'), dom.h2('Message list', style({ margin: '1ex 0 0 0' })))), dom.tr(dom.td('↓', ', j'), dom.td('down one message'), dom.td(attr.rowspan('6'), css('helpSideNote', { color: '#888', borderLeft: '2px solid', borderLeftColor: '#888', paddingLeft: '.5em' }), 'hold ctrl to only move focus', dom.br(), 'hold shift to expand selection')), [ + ].map(t => dom.tr(dom.td(t[0]), dom.td(t[1]))), dom.tr(dom.td(attr.colspan('2'), dom.h2('Message list', style({ margin: '1ex 0 0 0' })))), dom.tr(dom.td('↓', ', j'), dom.td('down one message'), dom.td(attr.rowspan('6'), css('helpSideNote', { color: '#888', borderLeft: '2px solid', borderLeftColor: '#888', paddingLeft: '.5em' }), dom.div('hold ctrl to only move focus', attr.title('ctrl-l and ctrl-u are left for the browser the handle')), dom.div('hold shift to expand selection'))), [ [['↑', ', k'], 'up one message'], ['PageDown, l', 'down one screen'], ['PageUp, h', 'up one screen'], @@ -2523,7 +2523,7 @@ const cmdHelp = async () => { ['O', 'show raw message'], ['ctrl p', 'print message'], ['I', 'toggle internals'], - ['ctrl I', 'toggle all headers'], + ['ctrl i', 'toggle all headers'], ['alt k, alt ArrowUp', 'scroll up'], ['alt j, alt ArrowDown', 'scroll down'], ['alt K', 'scroll to top'], @@ -3893,7 +3893,7 @@ const newMsgView = (miv, msglistView, listMailboxes, possibleLabels, messageLoad v: cmdViewAttachments, t: cmdShowText, T: cmdShowHTMLCycle, - 'ctrl I': cmdToggleHeaders, + 'ctrl i': cmdToggleHeaders, 'alt j': cmdDown, 'alt k': cmdUp, 'alt ArrowDown': cmdDown, @@ -5380,6 +5380,11 @@ const newMsglistView = (msgElem, activeMailbox, listMailboxes, setLocationHash, moveclick(i + 1, e.key === 'J'); } else if (e.key === 'PageUp' || e.key === 'h' || e.key === 'H' || e.key === 'PageDown' || e.key === 'l' || e.key === 'L') { + // Commonly bound to "focus to browser address bar", moving cursor to one page down + // without opening isn't useful enough. + if (e.key === 'l' && e.ctrlKey) { + return; + } if (msgitemViews.length > 0) { let n = Math.max(1, Math.floor(scrollElemHeight() / mlv.itemHeight()) - 1); if (e.key === 'PageUp' || e.key === 'h' || e.key === 'H') { @@ -5423,6 +5428,11 @@ const newMsglistView = (msgElem, activeMailbox, listMailboxes, setLocationHash, } } else if (e.key === 'u' || e.key === 'U') { + // Commonly bound to "view source", moving cursor to next unread message without + // opening isn't useful enough. + if (e.key === 'u' && e.ctrlKey) { + return; + } for (i = i < 0 ? 0 : i + 1; i < msgitemViews.length; i += 1) { if (!msgitemViews[i].messageitem.Message.Seen || msgitemViews[i].collapsed && msgitemViews[i].findDescendant(miv => !miv.messageitem.Message.Seen)) { moveclick(i, true); diff --git a/webmail/webmail.ts b/webmail/webmail.ts index 29b7d43..848140b 100644 --- a/webmail/webmail.ts +++ b/webmail/webmail.ts @@ -1212,7 +1212,12 @@ const cmdHelp = async () => { dom.tr( dom.td('↓', ', j'), dom.td('down one message'), - dom.td(attr.rowspan('6'), css('helpSideNote', {color: '#888', borderLeft: '2px solid', borderLeftColor: '#888', paddingLeft: '.5em'}), 'hold ctrl to only move focus', dom.br(), 'hold shift to expand selection'), + dom.td( + attr.rowspan('6'), + css('helpSideNote', {color: '#888', borderLeft: '2px solid', borderLeftColor: '#888', paddingLeft: '.5em'}), + dom.div('hold ctrl to only move focus', attr.title('ctrl-l and ctrl-u are left for the browser the handle')), + dom.div('hold shift to expand selection'), + ), ), [ [['↑', ', k'], 'up one message'], @@ -1273,7 +1278,7 @@ const cmdHelp = async () => { ['O', 'show raw message'], ['ctrl p', 'print message'], ['I', 'toggle internals'], - ['ctrl I', 'toggle all headers'], + ['ctrl i', 'toggle all headers'], ['alt k, alt ArrowUp', 'scroll up'], ['alt j, alt ArrowDown', 'scroll down'], @@ -3156,7 +3161,7 @@ const newMsgView = (miv: MsgitemView, msglistView: MsglistView, listMailboxes: l v: cmdViewAttachments, t: cmdShowText, T: cmdShowHTMLCycle, - 'ctrl I': cmdToggleHeaders, + 'ctrl i': cmdToggleHeaders, 'alt j': cmdDown, 'alt k': cmdUp, @@ -4981,6 +4986,12 @@ const newMsglistView = (msgElem: HTMLElement, activeMailbox: () => api.Mailbox | } else if (e.key === 'ArrowDown' || e.key === 'j' || e.key === 'J') { moveclick(i+1, e.key === 'J') } else if (e.key === 'PageUp' || e.key === 'h' || e.key === 'H' || e.key === 'PageDown' || e.key === 'l' || e.key === 'L') { + // Commonly bound to "focus to browser address bar", moving cursor to one page down + // without opening isn't useful enough. + if (e.key === 'l' && e.ctrlKey) { + return + } + if (msgitemViews.length > 0) { let n = Math.max(1, Math.floor(scrollElemHeight()/mlv.itemHeight())-1) if (e.key === 'PageUp' || e.key === 'h' || e.key === 'H') { @@ -5017,6 +5028,12 @@ const newMsglistView = (msgElem: HTMLElement, activeMailbox: () => api.Mailbox | moveclick(msgitemViews.indexOf(thrmiv), true) } } else if (e.key === 'u' || e.key === 'U') { + // Commonly bound to "view source", moving cursor to next unread message without + // opening isn't useful enough. + if (e.key === 'u' && e.ctrlKey) { + return + } + for (i = i < 0 ? 0 : i+1; i < msgitemViews.length; i += 1) { if (!msgitemViews[i].messageitem.Message.Seen || msgitemViews[i].collapsed && msgitemViews[i].findDescendant(miv => !miv.messageitem.Message.Seen)) { moveclick(i, true) From 3f727cf38094e69a99f15b455e995b735b85c53a Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Sat, 7 Dec 2024 12:32:54 +0100 Subject: [PATCH 02/12] webmail: move 2 config options from localstorage to the settings popup, storing their values on the server these settings are applied anywhere the webmail is open. the settings are for showing keyboard shortcuts in the lower right after a mouse interaction, and showing additional headers. the shorcuts were configurable in the "help" popup before. the additional headers were only configurable through the developer console before. the "mailto:" (un)register buttons are now in the settings popup too. --- store/account.go | 6 ++ webmail/api.json | 15 +++++ webmail/api.ts | 4 +- webmail/msg.js | 2 +- webmail/text.js | 2 +- webmail/webmail.js | 101 ++++++++++++++-------------------- webmail/webmail.ts | 134 ++++++++++++++++++++++----------------------- 7 files changed, 133 insertions(+), 131 deletions(-) diff --git a/store/account.go b/store/account.go index 846e286..46dc57a 100644 --- a/store/account.go +++ b/store/account.go @@ -741,6 +741,12 @@ type Settings struct { // Show HTML version of message by default, instead of plain text. ShowHTML bool + + // If true, don't show shortcuts in webmail after mouse interaction. + NoShowShortcuts bool + + // Additional headers to display in message view. E.g. Delivered-To, User-Agent, X-Mox-Reason. + ShowHeaders []string } // ViewMode how a message should be viewed: its text parts, html parts, or html diff --git a/webmail/api.json b/webmail/api.json index 2d6c4e2..f050b2e 100644 --- a/webmail/api.json +++ b/webmail/api.json @@ -1757,6 +1757,21 @@ "Typewords": [ "bool" ] + }, + { + "Name": "NoShowShortcuts", + "Docs": "If true, don't show shortcuts in webmail after mouse interaction.", + "Typewords": [ + "bool" + ] + }, + { + "Name": "ShowHeaders", + "Docs": "Additional headers to display in message view. E.g. Delivered-To, User-Agent, X-Mox-Reason.", + "Typewords": [ + "[]", + "string" + ] } ] }, diff --git a/webmail/api.ts b/webmail/api.ts index f7941d4..38534d2 100644 --- a/webmail/api.ts +++ b/webmail/api.ts @@ -220,6 +220,8 @@ export interface Settings { Quoting: Quoting ShowAddressSecurity: boolean // Whether to show the bars underneath the address input fields indicating starttls/dnssec/dane/mtasts/requiretls support by address. ShowHTML: boolean // Show HTML version of message by default, instead of plain text. + NoShowShortcuts: boolean // If true, don't show shortcuts in webmail after mouse interaction. + ShowHeaders?: string[] | null // Additional headers to display in message view. E.g. Delivered-To, User-Agent, X-Mox-Reason. } export interface Ruleset { @@ -604,7 +606,7 @@ export const types: TypenameMap = { "ForwardAttachments": {"Name":"ForwardAttachments","Docs":"","Fields":[{"Name":"MessageID","Docs":"","Typewords":["int64"]},{"Name":"Paths","Docs":"","Typewords":["[]","[]","int32"]}]}, "Mailbox": {"Name":"Mailbox","Docs":"","Fields":[{"Name":"ID","Docs":"","Typewords":["int64"]},{"Name":"Name","Docs":"","Typewords":["string"]},{"Name":"UIDValidity","Docs":"","Typewords":["uint32"]},{"Name":"UIDNext","Docs":"","Typewords":["UID"]},{"Name":"Archive","Docs":"","Typewords":["bool"]},{"Name":"Draft","Docs":"","Typewords":["bool"]},{"Name":"Junk","Docs":"","Typewords":["bool"]},{"Name":"Sent","Docs":"","Typewords":["bool"]},{"Name":"Trash","Docs":"","Typewords":["bool"]},{"Name":"Keywords","Docs":"","Typewords":["[]","string"]},{"Name":"HaveCounts","Docs":"","Typewords":["bool"]},{"Name":"Total","Docs":"","Typewords":["int64"]},{"Name":"Deleted","Docs":"","Typewords":["int64"]},{"Name":"Unread","Docs":"","Typewords":["int64"]},{"Name":"Unseen","Docs":"","Typewords":["int64"]},{"Name":"Size","Docs":"","Typewords":["int64"]}]}, "RecipientSecurity": {"Name":"RecipientSecurity","Docs":"","Fields":[{"Name":"STARTTLS","Docs":"","Typewords":["SecurityResult"]},{"Name":"MTASTS","Docs":"","Typewords":["SecurityResult"]},{"Name":"DNSSEC","Docs":"","Typewords":["SecurityResult"]},{"Name":"DANE","Docs":"","Typewords":["SecurityResult"]},{"Name":"RequireTLS","Docs":"","Typewords":["SecurityResult"]}]}, - "Settings": {"Name":"Settings","Docs":"","Fields":[{"Name":"ID","Docs":"","Typewords":["uint8"]},{"Name":"Signature","Docs":"","Typewords":["string"]},{"Name":"Quoting","Docs":"","Typewords":["Quoting"]},{"Name":"ShowAddressSecurity","Docs":"","Typewords":["bool"]},{"Name":"ShowHTML","Docs":"","Typewords":["bool"]}]}, + "Settings": {"Name":"Settings","Docs":"","Fields":[{"Name":"ID","Docs":"","Typewords":["uint8"]},{"Name":"Signature","Docs":"","Typewords":["string"]},{"Name":"Quoting","Docs":"","Typewords":["Quoting"]},{"Name":"ShowAddressSecurity","Docs":"","Typewords":["bool"]},{"Name":"ShowHTML","Docs":"","Typewords":["bool"]},{"Name":"NoShowShortcuts","Docs":"","Typewords":["bool"]},{"Name":"ShowHeaders","Docs":"","Typewords":["[]","string"]}]}, "Ruleset": {"Name":"Ruleset","Docs":"","Fields":[{"Name":"SMTPMailFromRegexp","Docs":"","Typewords":["string"]},{"Name":"MsgFromRegexp","Docs":"","Typewords":["string"]},{"Name":"VerifiedDomain","Docs":"","Typewords":["string"]},{"Name":"HeadersRegexp","Docs":"","Typewords":["{}","string"]},{"Name":"IsForward","Docs":"","Typewords":["bool"]},{"Name":"ListAllowDomain","Docs":"","Typewords":["string"]},{"Name":"AcceptRejectsToMailbox","Docs":"","Typewords":["string"]},{"Name":"Mailbox","Docs":"","Typewords":["string"]},{"Name":"Comment","Docs":"","Typewords":["string"]},{"Name":"VerifiedDNSDomain","Docs":"","Typewords":["Domain"]},{"Name":"ListAllowDNSDomain","Docs":"","Typewords":["Domain"]}]}, "EventStart": {"Name":"EventStart","Docs":"","Fields":[{"Name":"SSEID","Docs":"","Typewords":["int64"]},{"Name":"LoginAddress","Docs":"","Typewords":["MessageAddress"]},{"Name":"Addresses","Docs":"","Typewords":["[]","MessageAddress"]},{"Name":"DomainAddressConfigs","Docs":"","Typewords":["{}","DomainAddressConfig"]},{"Name":"MailboxName","Docs":"","Typewords":["string"]},{"Name":"Mailboxes","Docs":"","Typewords":["[]","Mailbox"]},{"Name":"RejectsMailbox","Docs":"","Typewords":["string"]},{"Name":"Settings","Docs":"","Typewords":["Settings"]},{"Name":"AccountPath","Docs":"","Typewords":["string"]},{"Name":"Version","Docs":"","Typewords":["string"]}]}, "DomainAddressConfig": {"Name":"DomainAddressConfig","Docs":"","Fields":[{"Name":"LocalpartCatchallSeparator","Docs":"","Typewords":["string"]},{"Name":"LocalpartCaseSensitive","Docs":"","Typewords":["bool"]}]}, diff --git a/webmail/msg.js b/webmail/msg.js index 071949f..9e1f1ed 100644 --- a/webmail/msg.js +++ b/webmail/msg.js @@ -311,7 +311,7 @@ var api; "ForwardAttachments": { "Name": "ForwardAttachments", "Docs": "", "Fields": [{ "Name": "MessageID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Paths", "Docs": "", "Typewords": ["[]", "[]", "int32"] }] }, "Mailbox": { "Name": "Mailbox", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "UIDValidity", "Docs": "", "Typewords": ["uint32"] }, { "Name": "UIDNext", "Docs": "", "Typewords": ["UID"] }, { "Name": "Archive", "Docs": "", "Typewords": ["bool"] }, { "Name": "Draft", "Docs": "", "Typewords": ["bool"] }, { "Name": "Junk", "Docs": "", "Typewords": ["bool"] }, { "Name": "Sent", "Docs": "", "Typewords": ["bool"] }, { "Name": "Trash", "Docs": "", "Typewords": ["bool"] }, { "Name": "Keywords", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "HaveCounts", "Docs": "", "Typewords": ["bool"] }, { "Name": "Total", "Docs": "", "Typewords": ["int64"] }, { "Name": "Deleted", "Docs": "", "Typewords": ["int64"] }, { "Name": "Unread", "Docs": "", "Typewords": ["int64"] }, { "Name": "Unseen", "Docs": "", "Typewords": ["int64"] }, { "Name": "Size", "Docs": "", "Typewords": ["int64"] }] }, "RecipientSecurity": { "Name": "RecipientSecurity", "Docs": "", "Fields": [{ "Name": "STARTTLS", "Docs": "", "Typewords": ["SecurityResult"] }, { "Name": "MTASTS", "Docs": "", "Typewords": ["SecurityResult"] }, { "Name": "DNSSEC", "Docs": "", "Typewords": ["SecurityResult"] }, { "Name": "DANE", "Docs": "", "Typewords": ["SecurityResult"] }, { "Name": "RequireTLS", "Docs": "", "Typewords": ["SecurityResult"] }] }, - "Settings": { "Name": "Settings", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["uint8"] }, { "Name": "Signature", "Docs": "", "Typewords": ["string"] }, { "Name": "Quoting", "Docs": "", "Typewords": ["Quoting"] }, { "Name": "ShowAddressSecurity", "Docs": "", "Typewords": ["bool"] }, { "Name": "ShowHTML", "Docs": "", "Typewords": ["bool"] }] }, + "Settings": { "Name": "Settings", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["uint8"] }, { "Name": "Signature", "Docs": "", "Typewords": ["string"] }, { "Name": "Quoting", "Docs": "", "Typewords": ["Quoting"] }, { "Name": "ShowAddressSecurity", "Docs": "", "Typewords": ["bool"] }, { "Name": "ShowHTML", "Docs": "", "Typewords": ["bool"] }, { "Name": "NoShowShortcuts", "Docs": "", "Typewords": ["bool"] }, { "Name": "ShowHeaders", "Docs": "", "Typewords": ["[]", "string"] }] }, "Ruleset": { "Name": "Ruleset", "Docs": "", "Fields": [{ "Name": "SMTPMailFromRegexp", "Docs": "", "Typewords": ["string"] }, { "Name": "MsgFromRegexp", "Docs": "", "Typewords": ["string"] }, { "Name": "VerifiedDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "HeadersRegexp", "Docs": "", "Typewords": ["{}", "string"] }, { "Name": "IsForward", "Docs": "", "Typewords": ["bool"] }, { "Name": "ListAllowDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "AcceptRejectsToMailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "Mailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "Comment", "Docs": "", "Typewords": ["string"] }, { "Name": "VerifiedDNSDomain", "Docs": "", "Typewords": ["Domain"] }, { "Name": "ListAllowDNSDomain", "Docs": "", "Typewords": ["Domain"] }] }, "EventStart": { "Name": "EventStart", "Docs": "", "Fields": [{ "Name": "SSEID", "Docs": "", "Typewords": ["int64"] }, { "Name": "LoginAddress", "Docs": "", "Typewords": ["MessageAddress"] }, { "Name": "Addresses", "Docs": "", "Typewords": ["[]", "MessageAddress"] }, { "Name": "DomainAddressConfigs", "Docs": "", "Typewords": ["{}", "DomainAddressConfig"] }, { "Name": "MailboxName", "Docs": "", "Typewords": ["string"] }, { "Name": "Mailboxes", "Docs": "", "Typewords": ["[]", "Mailbox"] }, { "Name": "RejectsMailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "Settings", "Docs": "", "Typewords": ["Settings"] }, { "Name": "AccountPath", "Docs": "", "Typewords": ["string"] }, { "Name": "Version", "Docs": "", "Typewords": ["string"] }] }, "DomainAddressConfig": { "Name": "DomainAddressConfig", "Docs": "", "Fields": [{ "Name": "LocalpartCatchallSeparator", "Docs": "", "Typewords": ["string"] }, { "Name": "LocalpartCaseSensitive", "Docs": "", "Typewords": ["bool"] }] }, diff --git a/webmail/text.js b/webmail/text.js index 2bc0569..47aac13 100644 --- a/webmail/text.js +++ b/webmail/text.js @@ -311,7 +311,7 @@ var api; "ForwardAttachments": { "Name": "ForwardAttachments", "Docs": "", "Fields": [{ "Name": "MessageID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Paths", "Docs": "", "Typewords": ["[]", "[]", "int32"] }] }, "Mailbox": { "Name": "Mailbox", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "UIDValidity", "Docs": "", "Typewords": ["uint32"] }, { "Name": "UIDNext", "Docs": "", "Typewords": ["UID"] }, { "Name": "Archive", "Docs": "", "Typewords": ["bool"] }, { "Name": "Draft", "Docs": "", "Typewords": ["bool"] }, { "Name": "Junk", "Docs": "", "Typewords": ["bool"] }, { "Name": "Sent", "Docs": "", "Typewords": ["bool"] }, { "Name": "Trash", "Docs": "", "Typewords": ["bool"] }, { "Name": "Keywords", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "HaveCounts", "Docs": "", "Typewords": ["bool"] }, { "Name": "Total", "Docs": "", "Typewords": ["int64"] }, { "Name": "Deleted", "Docs": "", "Typewords": ["int64"] }, { "Name": "Unread", "Docs": "", "Typewords": ["int64"] }, { "Name": "Unseen", "Docs": "", "Typewords": ["int64"] }, { "Name": "Size", "Docs": "", "Typewords": ["int64"] }] }, "RecipientSecurity": { "Name": "RecipientSecurity", "Docs": "", "Fields": [{ "Name": "STARTTLS", "Docs": "", "Typewords": ["SecurityResult"] }, { "Name": "MTASTS", "Docs": "", "Typewords": ["SecurityResult"] }, { "Name": "DNSSEC", "Docs": "", "Typewords": ["SecurityResult"] }, { "Name": "DANE", "Docs": "", "Typewords": ["SecurityResult"] }, { "Name": "RequireTLS", "Docs": "", "Typewords": ["SecurityResult"] }] }, - "Settings": { "Name": "Settings", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["uint8"] }, { "Name": "Signature", "Docs": "", "Typewords": ["string"] }, { "Name": "Quoting", "Docs": "", "Typewords": ["Quoting"] }, { "Name": "ShowAddressSecurity", "Docs": "", "Typewords": ["bool"] }, { "Name": "ShowHTML", "Docs": "", "Typewords": ["bool"] }] }, + "Settings": { "Name": "Settings", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["uint8"] }, { "Name": "Signature", "Docs": "", "Typewords": ["string"] }, { "Name": "Quoting", "Docs": "", "Typewords": ["Quoting"] }, { "Name": "ShowAddressSecurity", "Docs": "", "Typewords": ["bool"] }, { "Name": "ShowHTML", "Docs": "", "Typewords": ["bool"] }, { "Name": "NoShowShortcuts", "Docs": "", "Typewords": ["bool"] }, { "Name": "ShowHeaders", "Docs": "", "Typewords": ["[]", "string"] }] }, "Ruleset": { "Name": "Ruleset", "Docs": "", "Fields": [{ "Name": "SMTPMailFromRegexp", "Docs": "", "Typewords": ["string"] }, { "Name": "MsgFromRegexp", "Docs": "", "Typewords": ["string"] }, { "Name": "VerifiedDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "HeadersRegexp", "Docs": "", "Typewords": ["{}", "string"] }, { "Name": "IsForward", "Docs": "", "Typewords": ["bool"] }, { "Name": "ListAllowDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "AcceptRejectsToMailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "Mailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "Comment", "Docs": "", "Typewords": ["string"] }, { "Name": "VerifiedDNSDomain", "Docs": "", "Typewords": ["Domain"] }, { "Name": "ListAllowDNSDomain", "Docs": "", "Typewords": ["Domain"] }] }, "EventStart": { "Name": "EventStart", "Docs": "", "Fields": [{ "Name": "SSEID", "Docs": "", "Typewords": ["int64"] }, { "Name": "LoginAddress", "Docs": "", "Typewords": ["MessageAddress"] }, { "Name": "Addresses", "Docs": "", "Typewords": ["[]", "MessageAddress"] }, { "Name": "DomainAddressConfigs", "Docs": "", "Typewords": ["{}", "DomainAddressConfig"] }, { "Name": "MailboxName", "Docs": "", "Typewords": ["string"] }, { "Name": "Mailboxes", "Docs": "", "Typewords": ["[]", "Mailbox"] }, { "Name": "RejectsMailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "Settings", "Docs": "", "Typewords": ["Settings"] }, { "Name": "AccountPath", "Docs": "", "Typewords": ["string"] }, { "Name": "Version", "Docs": "", "Typewords": ["string"] }] }, "DomainAddressConfig": { "Name": "DomainAddressConfig", "Docs": "", "Fields": [{ "Name": "LocalpartCatchallSeparator", "Docs": "", "Typewords": ["string"] }, { "Name": "LocalpartCaseSensitive", "Docs": "", "Typewords": ["bool"] }] }, diff --git a/webmail/webmail.js b/webmail/webmail.js index 516c6b2..9cecf9b 100644 --- a/webmail/webmail.js +++ b/webmail/webmail.js @@ -311,7 +311,7 @@ var api; "ForwardAttachments": { "Name": "ForwardAttachments", "Docs": "", "Fields": [{ "Name": "MessageID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Paths", "Docs": "", "Typewords": ["[]", "[]", "int32"] }] }, "Mailbox": { "Name": "Mailbox", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "UIDValidity", "Docs": "", "Typewords": ["uint32"] }, { "Name": "UIDNext", "Docs": "", "Typewords": ["UID"] }, { "Name": "Archive", "Docs": "", "Typewords": ["bool"] }, { "Name": "Draft", "Docs": "", "Typewords": ["bool"] }, { "Name": "Junk", "Docs": "", "Typewords": ["bool"] }, { "Name": "Sent", "Docs": "", "Typewords": ["bool"] }, { "Name": "Trash", "Docs": "", "Typewords": ["bool"] }, { "Name": "Keywords", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "HaveCounts", "Docs": "", "Typewords": ["bool"] }, { "Name": "Total", "Docs": "", "Typewords": ["int64"] }, { "Name": "Deleted", "Docs": "", "Typewords": ["int64"] }, { "Name": "Unread", "Docs": "", "Typewords": ["int64"] }, { "Name": "Unseen", "Docs": "", "Typewords": ["int64"] }, { "Name": "Size", "Docs": "", "Typewords": ["int64"] }] }, "RecipientSecurity": { "Name": "RecipientSecurity", "Docs": "", "Fields": [{ "Name": "STARTTLS", "Docs": "", "Typewords": ["SecurityResult"] }, { "Name": "MTASTS", "Docs": "", "Typewords": ["SecurityResult"] }, { "Name": "DNSSEC", "Docs": "", "Typewords": ["SecurityResult"] }, { "Name": "DANE", "Docs": "", "Typewords": ["SecurityResult"] }, { "Name": "RequireTLS", "Docs": "", "Typewords": ["SecurityResult"] }] }, - "Settings": { "Name": "Settings", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["uint8"] }, { "Name": "Signature", "Docs": "", "Typewords": ["string"] }, { "Name": "Quoting", "Docs": "", "Typewords": ["Quoting"] }, { "Name": "ShowAddressSecurity", "Docs": "", "Typewords": ["bool"] }, { "Name": "ShowHTML", "Docs": "", "Typewords": ["bool"] }] }, + "Settings": { "Name": "Settings", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["uint8"] }, { "Name": "Signature", "Docs": "", "Typewords": ["string"] }, { "Name": "Quoting", "Docs": "", "Typewords": ["Quoting"] }, { "Name": "ShowAddressSecurity", "Docs": "", "Typewords": ["bool"] }, { "Name": "ShowHTML", "Docs": "", "Typewords": ["bool"] }, { "Name": "NoShowShortcuts", "Docs": "", "Typewords": ["bool"] }, { "Name": "ShowHeaders", "Docs": "", "Typewords": ["[]", "string"] }] }, "Ruleset": { "Name": "Ruleset", "Docs": "", "Fields": [{ "Name": "SMTPMailFromRegexp", "Docs": "", "Typewords": ["string"] }, { "Name": "MsgFromRegexp", "Docs": "", "Typewords": ["string"] }, { "Name": "VerifiedDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "HeadersRegexp", "Docs": "", "Typewords": ["{}", "string"] }, { "Name": "IsForward", "Docs": "", "Typewords": ["bool"] }, { "Name": "ListAllowDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "AcceptRejectsToMailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "Mailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "Comment", "Docs": "", "Typewords": ["string"] }, { "Name": "VerifiedDNSDomain", "Docs": "", "Typewords": ["Domain"] }, { "Name": "ListAllowDNSDomain", "Docs": "", "Typewords": ["Domain"] }] }, "EventStart": { "Name": "EventStart", "Docs": "", "Fields": [{ "Name": "SSEID", "Docs": "", "Typewords": ["int64"] }, { "Name": "LoginAddress", "Docs": "", "Typewords": ["MessageAddress"] }, { "Name": "Addresses", "Docs": "", "Typewords": ["[]", "MessageAddress"] }, { "Name": "DomainAddressConfigs", "Docs": "", "Typewords": ["{}", "DomainAddressConfig"] }, { "Name": "MailboxName", "Docs": "", "Typewords": ["string"] }, { "Name": "Mailboxes", "Docs": "", "Typewords": ["[]", "Mailbox"] }, { "Name": "RejectsMailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "Settings", "Docs": "", "Typewords": ["Settings"] }, { "Name": "AccountPath", "Docs": "", "Typewords": ["string"] }, { "Name": "Version", "Docs": "", "Typewords": ["string"] }] }, "DomainAddressConfig": { "Name": "DomainAddressConfig", "Docs": "", "Fields": [{ "Name": "LocalpartCatchallSeparator", "Docs": "", "Typewords": ["string"] }, { "Name": "LocalpartCaseSensitive", "Docs": "", "Typewords": ["bool"] }] }, @@ -1493,10 +1493,6 @@ To simulate slow API calls and SSE events: localStorage.setItem('sherpats-debug', JSON.stringify({waitMinMsec: 2000, waitMaxMsec: 4000})) -Show additional headers of messages: - - settingsPut({...settings, showHeaders: ['Delivered-To', 'User-Agent', 'X-Mailer', 'Message-Id', 'List-Id', 'List-Post', 'X-Mox-Reason', 'TLS-Required']}) - Enable logging and reload afterwards: localStorage.setItem('log', 'yes') @@ -1571,7 +1567,6 @@ try { catch (err) { } let accountSettings; const defaultSettings = { - showShortcuts: true, mailboxesWidth: 240, layout: 'auto', leftWidthPct: 50, @@ -1584,7 +1579,6 @@ const defaultSettings = { ignoreErrorsUntil: 0, mailboxCollapsed: {}, showAllHeaders: false, - showHeaders: [], threading: api.ThreadMode.ThreadOn, checkConsistency: location.hostname === 'localhost', composeWidth: 0, @@ -1619,13 +1613,6 @@ const parseSettings = () => { if (!mailboxCollapsed || typeof mailboxCollapsed !== 'object') { mailboxCollapsed = def.mailboxCollapsed; } - const getStringArray = (k) => { - const v = x[k]; - if (v && Array.isArray(v) && (v.length === 0 || typeof v[0] === 'string')) { - return v; - } - return def[k]; - }; return { refine: getString('refine'), orderAsc: getBool('orderAsc'), @@ -1637,10 +1624,8 @@ const parseSettings = () => { msglistfromPct: getInt('msglistfromPct'), ignoreErrorsUntil: getInt('ignoreErrorsUntil'), layout: getString('layout', 'auto', 'leftright', 'topbottom'), - showShortcuts: getBool('showShortcuts'), mailboxCollapsed: mailboxCollapsed, showAllHeaders: getBool('showAllHeaders'), - showHeaders: getStringArray('showHeaders'), threading: getString('threading', api.ThreadMode.ThreadOff, api.ThreadMode.ThreadOn, api.ThreadMode.ThreadUnread), checkConsistency: getBool('checkConsistency'), composeWidth: getInt('composeWidth'), @@ -1764,7 +1749,7 @@ const envelopeIdentity = (l) => { let shortcutElem = dom.div(css('shortcutFlash', { fontSize: '2em', position: 'absolute', left: '.25em', bottom: '.25em', backgroundColor: '#888', padding: '0.25em .5em', color: 'white', borderRadius: '.15em' })); let shortcutTimer = 0; const showShortcut = (c) => { - if (!settings.showShortcuts) { + if (accountSettings?.NoShowShortcuts) { return; } if (shortcutTimer) { @@ -2446,6 +2431,8 @@ const cmdSettings = async () => { let quoting; let showAddressSecurity; let showHTML; + let showShortcuts; + let showHeaders; if (!accountSettings) { throw new Error('No account settings fetched yet.'); } @@ -2458,15 +2445,43 @@ const cmdSettings = async () => { Quoting: quoting.value, ShowAddressSecurity: showAddressSecurity.checked, ShowHTML: showHTML.checked, + NoShowShortcuts: !showShortcuts.checked, + ShowHeaders: showHeaders.value.split('\n').map(s => s.trim()).filter(s => !!s), }; await withDisabled(fieldset, client.SettingsSave(accSet)); accountSettings = accSet; remove(); - }, fieldset = dom.fieldset(dom.label(style({ margin: '1ex 0', display: 'block' }), dom.div('Signature'), signature = dom.textarea(new String(accountSettings.Signature), style({ width: '100%' }), attr.rows('' + Math.max(3, 1 + accountSettings.Signature.split('\n').length)))), dom.label(style({ margin: '1ex 0', display: 'block' }), dom.div('Reply above/below original'), attr.title('Auto: If text is selected, only the replied text is quoted and editing starts below. Otherwise, the full message is quoted and editing starts at the top.'), quoting = dom.select(dom.option(attr.value(''), 'Auto'), dom.option(attr.value('bottom'), 'Bottom', accountSettings.Quoting === api.Quoting.Bottom ? attr.selected('') : []), dom.option(attr.value('top'), 'Top', accountSettings.Quoting === api.Quoting.Top ? attr.selected('') : []))), dom.label(style({ margin: '1ex 0', display: 'block' }), showAddressSecurity = dom.input(attr.type('checkbox'), accountSettings.ShowAddressSecurity ? attr.checked('') : []), ' Show address security indications', attr.title('Show bars underneath address input fields, indicating support for STARTTLS/DNSSEC/DANE/MTA-STS/RequireTLS.')), dom.label(style({ margin: '1ex 0', display: 'block' }), showHTML = dom.input(attr.type('checkbox'), accountSettings.ShowHTML ? attr.checked('') : []), ' Show HTML instead of text version by default'), dom.br(), dom.div(dom.submitbutton('Save'))))); + }, fieldset = dom.fieldset(dom.label(style({ margin: '1ex 0', display: 'block' }), dom.div('Signature'), signature = dom.textarea(new String(accountSettings.Signature), style({ width: '100%' }), attr.rows('' + Math.max(3, 1 + accountSettings.Signature.split('\n').length)))), dom.label(style({ margin: '1ex 0', display: 'block' }), dom.div('Reply above/below original'), attr.title('Auto: If text is selected, only the replied text is quoted and editing starts below. Otherwise, the full message is quoted and editing starts at the top.'), quoting = dom.select(dom.option(attr.value(''), 'Auto'), dom.option(attr.value('bottom'), 'Bottom', accountSettings.Quoting === api.Quoting.Bottom ? attr.selected('') : []), dom.option(attr.value('top'), 'Top', accountSettings.Quoting === api.Quoting.Top ? attr.selected('') : []))), dom.label(style({ margin: '1ex 0', display: 'block' }), showAddressSecurity = dom.input(attr.type('checkbox'), accountSettings.ShowAddressSecurity ? attr.checked('') : []), ' Show address security indications', attr.title('Show bars underneath address input fields, indicating support for STARTTLS/DNSSEC/DANE/MTA-STS/RequireTLS.')), dom.label(style({ margin: '1ex 0', display: 'block' }), showHTML = dom.input(attr.type('checkbox'), accountSettings.ShowHTML ? attr.checked('') : []), ' Show email as HTML instead of text by default for first-time senders', attr.title('Whether to show HTML or text is remembered per sender. This sets the default for unknown correspondents.')), dom.label(style({ margin: '1ex 0', display: 'block' }), showShortcuts = dom.input(attr.type('checkbox'), accountSettings.NoShowShortcuts ? [] : attr.checked('')), ' Show shortcut keys in bottom left after interaction with mouse'), dom.label(style({ margin: '1ex 0', display: 'block' }), dom.div('Show additional headers'), showHeaders = dom.textarea(new String((accountSettings.ShowHeaders || []).join('\n')), style({ width: '100%' }), attr.rows('' + Math.max(3, 1 + (accountSettings.ShowHeaders || []).length))), dom.div(style({ fontStyle: 'italic' }), 'One header name per line, for example Delivered-To, X-Mox-Reason, User-Agent, ...')), dom.div(style({ marginTop: '2ex' }), 'Register "mailto:" links with the browser/operating system to compose a message in webmail.', dom.br(), dom.clickbutton('Register', attr.title('In most browsers, registering is only allowed on HTTPS URLs. Your browser may ask for confirmation. If nothing appears to happen, the registration may already have been present.'), function click() { + if (!window.navigator.registerProtocolHandler) { + window.alert('Registering a protocol handler ("mailto:") is not supported by your browser.'); + return; + } + try { + window.navigator.registerProtocolHandler('mailto', '#compose %s'); + window.alert('"mailto:"-links have been registered'); + } + catch (err) { + window.alert('Error registering "mailto:" protocol handler: ' + errmsg(err)); + } + }), ' ', dom.clickbutton('Unregister', attr.title('Not all browsers implement unregistering via JavaScript.'), function click() { + // Not supported on firefox at the time of writing, and the signature is not in the types. + if (!window.navigator.unregisterProtocolHandler) { + window.alert('Unregistering a protocol handler ("mailto:") via JavaScript is not supported by your browser. See your browser settings to unregister.'); + return; + } + try { + window.navigator.unregisterProtocolHandler('mailto', '#compose %s'); + } + catch (err) { + window.alert('Error unregistering "mailto:" protocol handler: ' + errmsg(err)); + return; + } + window.alert('"mailto:" protocol handler unregistered.'); + })), dom.br(), dom.div(dom.submitbutton('Save'))))); }; // Show help popup, with shortcuts and basic explanation. const cmdHelp = async () => { - const remove = popup(css('popupHelp', { padding: '1em 1em 2em 1em' }), dom.h1('Help and keyboard shortcuts'), dom.div(style({ display: 'flex' }), dom.div(style({ width: '40em' }), dom.table(dom.tr(dom.td(attr.colspan('2'), dom.h2('Global', style({ margin: '0' })))), [ + popup(css('popupHelp', { padding: '1em 1em 2em 1em' }), dom.h1('Help and keyboard shortcuts'), dom.div(style({ display: 'flex' }), dom.div(style({ width: '40em' }), dom.table(dom.tr(dom.td(attr.colspan('2'), dom.h2('Global', style({ margin: '0' })))), [ ['c', 'compose new message'], ['/', 'search'], ['i', 'open inbox'], @@ -2534,42 +2549,7 @@ const cmdHelp = async () => { ['0', 'first attachment'], ['$', 'next attachment'], ['d', 'download'], - ].map(t => dom.tr(dom.td(t[0]), dom.td(t[1])))), dom.div(style({ marginTop: '2ex', marginBottom: '1ex' }), dom.span('Underdotted text', attr.title('Underdotted text shows additional information on hover.')), ' show an explanation or additional information when hovered.'), dom.div(style({ marginBottom: '1ex' }), 'Multiple messages can be selected by clicking messages while holding the control and/or shift keys. Dragging messages and dropping them on a mailbox moves the messages to that mailbox.'), dom.div(style({ marginBottom: '1ex' }), 'Text that changes ', dom.span(attr.title('Unicode blocks, e.g. from basic latin to cyrillic, or to emoticons.'), '"character groups"'), ' without whitespace has an ', dom.span(dom._class('scriptswitch'), 'orange underline'), ', which can be a sign of an intent to mislead (e.g. phishing).'), settings.showShortcuts ? - dom.div(style({ marginTop: '2ex' }), 'Shortcut keys for mouse operation are shown in the bottom left. ', dom.clickbutton('Disable', function click() { - settingsPut({ ...settings, showShortcuts: false }); - remove(); - cmdHelp(); - })) : - dom.div(style({ marginTop: '2ex' }), 'Shortcut keys for mouse operation are currently not shown. ', dom.clickbutton('Enable', function click() { - settingsPut({ ...settings, showShortcuts: true }); - remove(); - cmdHelp(); - })), dom.div(style({ marginTop: '2ex' }), 'To start composing a message when opening a "mailto:" link, register this application with your browser/system. ', dom.clickbutton('Register', attr.title('In most browsers, registering is only allowed on HTTPS URLs. Your browser may ask for confirmation. If nothing appears to happen, the registration may already have been present.'), function click() { - if (!window.navigator.registerProtocolHandler) { - window.alert('Registering a protocol handler ("mailto:") is not supported by your browser.'); - return; - } - try { - window.navigator.registerProtocolHandler('mailto', '#compose %s'); - } - catch (err) { - window.alert('Error registering "mailto:" protocol handler: ' + errmsg(err)); - } - }), ' ', dom.clickbutton('Unregister', attr.title('Not all browsers implement unregistering via JavaScript.'), function click() { - // Not supported on firefox at the time of writing, and the signature is not in the types. - if (!window.navigator.unregisterProtocolHandler) { - window.alert('Unregistering a protocol handler ("mailto:") via JavaScript is not supported by your browser. See your browser settings to unregister.'); - return; - } - try { - window.navigator.unregisterProtocolHandler('mailto', '#compose %s'); - } - catch (err) { - window.alert('Error unregistering "mailto:" protocol handler: ' + errmsg(err)); - return; - } - window.alert('"mailto:" protocol handler unregistered.'); - })), 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'))))); + ].map(t => dom.tr(dom.td(t[0]), dom.td(t[1])))), dom.div(style({ marginTop: '2ex', marginBottom: '1ex' }), dom.span('Underdotted text', attr.title('Underdotted text shows additional information on hover.')), ' show an explanation or additional information when hovered.'), dom.div(style({ marginBottom: '1ex' }), 'Multiple messages can be selected by clicking messages while holding the control and/or shift keys. Dragging messages and dropping them on a mailbox moves the messages to that mailbox.'), dom.div(style({ marginBottom: '1ex' }), 'Text that changes ', dom.span(attr.title('Unicode blocks, e.g. from basic latin to cyrillic, or to emoticons.'), '"character groups"'), ' without whitespace has an ', dom.span(dom._class('scriptswitch'), 'orange underline'), ', which can be a sign of an intent to mislead (e.g. phishing).'), 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 @@ -3944,7 +3924,7 @@ const newMsgView = (miv, msglistView, listMailboxes, possibleLabels, messageLoad }))); }; loadButtons(parsedMessageOpt || null); - loadMsgheaderView(msgheaderElem, miv.messageitem, settings.showHeaders, refineKeyword, false); + loadMsgheaderView(msgheaderElem, miv.messageitem, accountSettings.ShowHeaders || [], refineKeyword, false); const headerTextMildStyle = css('headerTextMild', { textAlign: 'right', color: styles.colorMild }); const loadHeaderDetails = (pm) => { if (msgheaderdetailsElem) { @@ -4077,13 +4057,14 @@ const newMsgView = (miv, msglistView, listMailboxes, possibleLabels, messageLoad renderAttachments(); // Rerender opaciy on inline images. }; const loadMoreHeaders = (pm) => { - if (settings.showHeaders.length === 0) { + const hl = accountSettings.ShowHeaders || []; + if (hl.length === 0) { return; } - for (let i = 0; i < settings.showHeaders.length; i++) { + for (let i = 0; i < hl.length; i++) { msgheaderElem.children[msgheaderElem.children.length - 1].remove(); } - settings.showHeaders.forEach(k => { + hl.forEach(k => { const vl = pm.Headers?.[k]; if (!vl || vl.length === 0) { return; @@ -4102,7 +4083,7 @@ const newMsgView = (miv, msglistView, listMailboxes, possibleLabels, messageLoad updateKeywords: async (modseq, keywords) => { mi.Message.ModSeq = modseq; mi.Message.Keywords = keywords; - loadMsgheaderView(msgheaderElem, miv.messageitem, settings.showHeaders, refineKeyword, false); + loadMsgheaderView(msgheaderElem, miv.messageitem, accountSettings.ShowHeaders || [], refineKeyword, false); loadMoreHeaders(await parsedMessagePromise); }, }; diff --git a/webmail/webmail.ts b/webmail/webmail.ts index 848140b..6b2b905 100644 --- a/webmail/webmail.ts +++ b/webmail/webmail.ts @@ -50,10 +50,6 @@ To simulate slow API calls and SSE events: localStorage.setItem('sherpats-debug', JSON.stringify({waitMinMsec: 2000, waitMaxMsec: 4000})) -Show additional headers of messages: - - settingsPut({...settings, showHeaders: ['Delivered-To', 'User-Agent', 'X-Mailer', 'Message-Id', 'List-Id', 'List-Post', 'X-Mox-Reason', 'TLS-Required']}) - Enable logging and reload afterwards: localStorage.setItem('log', 'yes') @@ -147,7 +143,6 @@ try { let accountSettings: api.Settings const defaultSettings = { - showShortcuts: true, // Whether to briefly show shortcuts in bottom left when a button is clicked that has a keyboard shortcut. mailboxesWidth: 240, layout: 'auto', // Automatic switching between left/right and top/bottom layout, based on screen width. leftWidthPct: 50, // Split in percentage of remaining width for left/right layout. @@ -160,7 +155,6 @@ const defaultSettings = { ignoreErrorsUntil: 0, // For unhandled javascript errors/rejected promises, we normally show a popup for details, but users can ignore them for a week at a time. mailboxCollapsed: {} as {[mailboxID: number]: boolean}, // Mailboxes that are collapsed. showAllHeaders: false, // Whether to show all message headers. - showHeaders: [] as string[], // Additional message headers to show. threading: api.ThreadMode.ThreadOn, checkConsistency: location.hostname === 'localhost', // Enable UI update consistency checks, default only for local development. composeWidth: 0, @@ -195,13 +189,6 @@ const parseSettings = (): typeof defaultSettings => { if (!mailboxCollapsed || typeof mailboxCollapsed !== 'object') { mailboxCollapsed = def.mailboxCollapsed } - const getStringArray = (k: string): string[] => { - const v = x[k] - if (v && Array.isArray(v) && (v.length === 0 || typeof v[0] === 'string')) { - return v - } - return def[k] as string[] - } return { refine: getString('refine'), @@ -214,10 +201,8 @@ const parseSettings = (): typeof defaultSettings => { msglistfromPct: getInt('msglistfromPct'), ignoreErrorsUntil: getInt('ignoreErrorsUntil'), layout: getString('layout', 'auto', 'leftright', 'topbottom'), - showShortcuts: getBool('showShortcuts'), mailboxCollapsed: mailboxCollapsed, showAllHeaders: getBool('showAllHeaders'), - showHeaders: getStringArray('showHeaders'), threading: getString('threading', api.ThreadMode.ThreadOff, api.ThreadMode.ThreadOn, api.ThreadMode.ThreadUnread) as api.ThreadMode, checkConsistency: getBool('checkConsistency'), composeWidth: getInt('composeWidth'), @@ -387,7 +372,7 @@ const envelopeIdentity = (l: api.MessageAddress[]): api.MessageAddress | null => let shortcutElem = dom.div(css('shortcutFlash', {fontSize: '2em', position: 'absolute', left: '.25em', bottom: '.25em', backgroundColor: '#888', padding: '0.25em .5em', color: 'white', borderRadius: '.15em'})) let shortcutTimer = 0 const showShortcut = (c: string) => { - if (!settings.showShortcuts) { + if (accountSettings?.NoShowShortcuts) { return } if (shortcutTimer) { @@ -1119,6 +1104,8 @@ const cmdSettings = async () => { let quoting: HTMLSelectElement let showAddressSecurity: HTMLInputElement let showHTML: HTMLInputElement + let showShortcuts: HTMLInputElement + let showHeaders: HTMLTextAreaElement if (!accountSettings) { throw new Error('No account settings fetched yet.') @@ -1137,6 +1124,8 @@ const cmdSettings = async () => { Quoting: quoting.value as api.Quoting, ShowAddressSecurity: showAddressSecurity.checked, ShowHTML: showHTML.checked, + NoShowShortcuts: !showShortcuts.checked, + ShowHeaders: showHeaders.value.split('\n').map(s => s.trim()).filter(s => !!s), } await withDisabled(fieldset, client.SettingsSave(accSet)) accountSettings = accSet @@ -1171,8 +1160,61 @@ const cmdSettings = async () => { dom.label( style({margin: '1ex 0', display: 'block'}), showHTML=dom.input(attr.type('checkbox'), accountSettings.ShowHTML ? attr.checked('') : []), - ' Show HTML instead of text version by default', + ' Show email as HTML instead of text by default for first-time senders', + attr.title('Whether to show HTML or text is remembered per sender. This sets the default for unknown correspondents.'), ), + + dom.label( + style({margin: '1ex 0', display: 'block'}), + showShortcuts=dom.input(attr.type('checkbox'), accountSettings.NoShowShortcuts ? [] : attr.checked('')), + ' Show shortcut keys in bottom left after interaction with mouse', + ), + + dom.label( + style({margin: '1ex 0', display: 'block'}), + dom.div('Show additional headers'), + showHeaders=dom.textarea( + new String((accountSettings.ShowHeaders || []).join('\n')), + style({width: '100%'}), + attr.rows(''+Math.max(3, 1+(accountSettings.ShowHeaders || []).length)), + ), + dom.div(style({fontStyle: 'italic'}), 'One header name per line, for example Delivered-To, X-Mox-Reason, User-Agent, ...'), + ), + + + dom.div( + style({marginTop: '2ex'}), + 'Register "mailto:" links with the browser/operating system to compose a message in webmail.', + dom.br(), + dom.clickbutton('Register', attr.title('In most browsers, registering is only allowed on HTTPS URLs. Your browser may ask for confirmation. If nothing appears to happen, the registration may already have been present.'), function click() { + if (!window.navigator.registerProtocolHandler) { + window.alert('Registering a protocol handler ("mailto:") is not supported by your browser.') + return + } + try { + window.navigator.registerProtocolHandler('mailto', '#compose %s') + window.alert('"mailto:"-links have been registered') + } catch (err) { + window.alert('Error registering "mailto:" protocol handler: '+errmsg(err)) + } + }), + ' ', + dom.clickbutton('Unregister', attr.title('Not all browsers implement unregistering via JavaScript.'), function click() { + // Not supported on firefox at the time of writing, and the signature is not in the types. + if (!(window.navigator as any).unregisterProtocolHandler) { + window.alert('Unregistering a protocol handler ("mailto:") via JavaScript is not supported by your browser. See your browser settings to unregister.') + return + } + try { + (window.navigator as any).unregisterProtocolHandler('mailto', '#compose %s') + } catch (err) { + window.alert('Error unregistering "mailto:" protocol handler: '+errmsg(err)) + return + } + window.alert('"mailto:" protocol handler unregistered.') + }), + ), + dom.br(), dom.div( dom.submitbutton('Save'), @@ -1184,7 +1226,7 @@ const cmdSettings = async () => { // Show help popup, with shortcuts and basic explanation. const cmdHelp = async () => { - const remove = popup( + popup( css('popupHelp', {padding: '1em 1em 2em 1em'}), dom.h1('Help and keyboard shortcuts'), dom.div(style({display: 'flex'}), @@ -1299,51 +1341,6 @@ const cmdHelp = async () => { dom.div(style({marginBottom: '1ex'}), 'Multiple messages can be selected by clicking messages while holding the control and/or shift keys. Dragging messages and dropping them on a mailbox moves the messages to that mailbox.'), dom.div(style({marginBottom: '1ex'}), 'Text that changes ', dom.span(attr.title('Unicode blocks, e.g. from basic latin to cyrillic, or to emoticons.'), '"character groups"'), ' without whitespace has an ', dom.span(dom._class('scriptswitch'), 'orange underline'), ', which can be a sign of an intent to mislead (e.g. phishing).'), - settings.showShortcuts ? - dom.div(style({marginTop: '2ex'}), 'Shortcut keys for mouse operation are shown in the bottom left. ', - dom.clickbutton('Disable', function click() { - settingsPut({...settings, showShortcuts: false}) - remove() - cmdHelp() - }) - ) : - dom.div(style({marginTop: '2ex'}), 'Shortcut keys for mouse operation are currently not shown. ', - dom.clickbutton('Enable', function click() { - settingsPut({...settings, showShortcuts: true}) - remove() - cmdHelp() - }) - ), - dom.div( - style({marginTop: '2ex'}), - 'To start composing a message when opening a "mailto:" link, register this application with your browser/system. ', - dom.clickbutton('Register', attr.title('In most browsers, registering is only allowed on HTTPS URLs. Your browser may ask for confirmation. If nothing appears to happen, the registration may already have been present.'), function click() { - if (!window.navigator.registerProtocolHandler) { - window.alert('Registering a protocol handler ("mailto:") is not supported by your browser.') - return - } - try { - window.navigator.registerProtocolHandler('mailto', '#compose %s') - } catch (err) { - window.alert('Error registering "mailto:" protocol handler: '+errmsg(err)) - } - }), - ' ', - dom.clickbutton('Unregister', attr.title('Not all browsers implement unregistering via JavaScript.'), function click() { - // Not supported on firefox at the time of writing, and the signature is not in the types. - if (!(window.navigator as any).unregisterProtocolHandler) { - window.alert('Unregistering a protocol handler ("mailto:") via JavaScript is not supported by your browser. See your browser settings to unregister.') - return - } - try { - (window.navigator as any).unregisterProtocolHandler('mailto', '#compose %s') - } catch (err) { - window.alert('Error unregistering "mailto:" protocol handler: '+errmsg(err)) - return - } - window.alert('"mailto:" protocol handler unregistered.') - }), - ), 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')), ), ), @@ -3256,7 +3253,7 @@ const newMsgView = (miv: MsgitemView, msglistView: MsglistView, listMailboxes: l } loadButtons(parsedMessageOpt || null) - loadMsgheaderView(msgheaderElem, miv.messageitem, settings.showHeaders, refineKeyword, false) + loadMsgheaderView(msgheaderElem, miv.messageitem, accountSettings.ShowHeaders || [], refineKeyword, false) const headerTextMildStyle = css('headerTextMild', {textAlign: 'right', color: styles.colorMild}) @@ -3515,13 +3512,14 @@ const newMsgView = (miv: MsgitemView, msglistView: MsglistView, listMailboxes: l } const loadMoreHeaders = (pm: api.ParsedMessage) => { - if (settings.showHeaders.length === 0) { + const hl = accountSettings.ShowHeaders || [] + if (hl.length === 0) { return } - for (let i = 0; i < settings.showHeaders.length; i++) { + for (let i = 0; i < hl.length; i++) { msgheaderElem.children[msgheaderElem.children.length-1].remove() } - settings.showHeaders.forEach(k => { + hl.forEach(k => { const vl = pm.Headers?.[k] if (!vl || vl.length === 0) { return @@ -3544,7 +3542,7 @@ const newMsgView = (miv: MsgitemView, msglistView: MsglistView, listMailboxes: l updateKeywords: async (modseq: number, keywords: string[]) => { mi.Message.ModSeq = modseq mi.Message.Keywords = keywords - loadMsgheaderView(msgheaderElem, miv.messageitem, settings.showHeaders, refineKeyword, false) + loadMsgheaderView(msgheaderElem, miv.messageitem, accountSettings.ShowHeaders || [], refineKeyword, false) loadMoreHeaders(await parsedMessagePromise) }, } From 0871bf5219c2dff8b7903c5f1ecb441a5544892a Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Sat, 7 Dec 2024 13:05:09 +0100 Subject: [PATCH 03/12] move checking whether a message needs smtputf8 (has utf8 in any of the header sections) to package message --- main.go | 32 +++----------------------------- message/part.go | 33 +++++++++++++++++++++++++++++++++ smtpserver/server.go | 41 +++++++++++++---------------------------- 3 files changed, 49 insertions(+), 57 deletions(-) diff --git a/main.go b/main.go index ff19afa..41b6eb9 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,6 @@ package main import ( - "bufio" "bytes" "context" "crypto" @@ -3519,35 +3518,10 @@ func cmdMessageParse(c *cmd) { err = enc.Encode(part) xcheckf(err, "write") - hasNonASCII := func(r io.Reader) bool { - br := bufio.NewReader(r) - for { - b, err := br.ReadByte() - if err == io.EOF { - break - } - xcheckf(err, "read header") - if b > 0x7f { - return true - } - } - return false - } - - var walk func(p *message.Part) bool - walk = func(p *message.Part) bool { - if hasNonASCII(p.HeaderReader()) { - return true - } - for _, pp := range p.Parts { - if walk(&pp) { - return true - } - } - return false - } if smtputf8 { - fmt.Println("message needs smtputf8:", walk(&part)) + needs, err := part.NeedsSMTPUTF8() + xcheckf(err, "checking if message needs smtputf8") + fmt.Println("message needs smtputf8:", needs) } } diff --git a/message/part.go b/message/part.go index bf1b8b7..faba013 100644 --- a/message/part.go +++ b/message/part.go @@ -21,6 +21,7 @@ import ( "net/textproto" "strings" "time" + "unicode" "golang.org/x/text/encoding/ianaindex" @@ -598,6 +599,38 @@ func (p *Part) IsDSN() bool { (p.Parts[1].MediaSubType == "DELIVERY-STATUS" || p.Parts[1].MediaSubType == "GLOBAL-DELIVERY-STATUS") } +func hasNonASCII(r io.Reader) (bool, error) { + br := bufio.NewReader(r) + for { + b, err := br.ReadByte() + if err == io.EOF { + break + } else if err != nil { + return false, err + } + if b > unicode.MaxASCII { + return true, nil + } + } + return false, nil +} + +// NeedsSMTPUTF8 returns whether the part needs the SMTPUTF8 extension to be +// transported, due to non-ascii in message headers. +func (p *Part) NeedsSMTPUTF8() (bool, error) { + if has, err := hasNonASCII(p.HeaderReader()); err != nil { + return false, fmt.Errorf("reading header: %w", err) + } else if has { + return true, nil + } + for _, pp := range p.Parts { + if has, err := pp.NeedsSMTPUTF8(); err != nil || has { + return has, err + } + } + return false, nil +} + var ErrParamEncoding = errors.New("bad header parameter encoding") // DispositionFilename tries to parse the disposition header and the "filename" diff --git a/smtpserver/server.go b/smtpserver/server.go index e90f0c9..424c88c 100644 --- a/smtpserver/server.go +++ b/smtpserver/server.go @@ -1940,47 +1940,32 @@ func (c *conn) cmdRcpt(p *parser) { c.bwritecodeline(smtp.C250Completed, smtp.SeAddr1Other0, "now on the list", nil) } -// ../rfc/6531:497 -func (c *conn) isSMTPUTF8Required(part *message.Part) bool { - hasNonASCII := func(r io.Reader) bool { - br := bufio.NewReader(r) - for { - b, err := br.ReadByte() - if err == io.EOF { - break - } - xcheckf(err, "read header") - if b > unicode.MaxASCII { - return true - } - } - return false - } - var hasNonASCIIPartHeader func(p *message.Part) bool - hasNonASCIIPartHeader = func(p *message.Part) bool { - if hasNonASCII(p.HeaderReader()) { +func hasNonASCII(s string) bool { + for _, c := range []byte(s) { + if c > unicode.MaxASCII { return true } - for _, pp := range p.Parts { - if hasNonASCIIPartHeader(&pp) { - return true - } - } - return false } + return false +} +// ../rfc/6531:497 +func (c *conn) isSMTPUTF8Required(part *message.Part) bool { // Check "MAIL FROM". - if hasNonASCII(strings.NewReader(string(c.mailFrom.Localpart))) { + if hasNonASCII(string(c.mailFrom.Localpart)) { return true } // Check all "RCPT TO". for _, rcpt := range c.recipients { - if hasNonASCII(strings.NewReader(string(rcpt.Addr.Localpart))) { + if hasNonASCII(string(rcpt.Addr.Localpart)) { return true } } + // Check header in all message parts. - return hasNonASCIIPartHeader(part) + smtputf8, err := part.NeedsSMTPUTF8() + xcheckf(err, "checking if smtputf8 is required") + return smtputf8 } // ../rfc/5321:1992 ../rfc/5321:1098 From 69a4995449deac053c44f4bf4c3930eb578deffa Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Sat, 7 Dec 2024 13:57:07 +0100 Subject: [PATCH 04/12] move func PartStructure from webhook to queue, so it isn't tracked anymore for apidiff changes the types in webhook should be subjected to apidiff'ing, this was a shared function. it is better off in package queue. also change the apidiff script so it leaves apidiff/next.txt empty when there aren't any changes. makes it easier to rotate the files after releases where nothing changed (a common occurrence). --- apidiff.sh | 30 ++++++++++++++++++++---------- apidiff/next.txt | 5 +++++ queue/hook.go | 35 ++++++++++++++++++++++++++++++++++- queue/hook_test.go | 2 +- webapisrv/server.go | 3 +-- webapisrv/server_test.go | 3 +-- webhook/webhook.go | 37 ------------------------------------- 7 files changed, 62 insertions(+), 53 deletions(-) diff --git a/apidiff.sh b/apidiff.sh index 6024096..d30b7cb 100755 --- a/apidiff.sh +++ b/apidiff.sh @@ -8,20 +8,30 @@ if ! test -d tmp/mox-$prevversion; then fi (rm -r tmp/apidiff || exit 0) mkdir -p tmp/apidiff/$prevversion tmp/apidiff/next -(rm apidiff/next.txt || exit 0) -( -echo "Below are the incompatible changes between $prevversion and next, per package." -echo -) >>apidiff/next.txt +(rm apidiff/next.txt apidiff/next.txt.new 2>/dev/null || exit 0) for p in $(cat apidiff/packages.txt); do if ! test -d tmp/mox-$prevversion/$p; then continue fi (cd tmp/mox-$prevversion && apidiff -w ../apidiff/$prevversion/$p.api ./$p) apidiff -w tmp/apidiff/next/$p.api ./$p - ( - echo '#' $p - apidiff -incompatible tmp/apidiff/$prevversion/$p.api tmp/apidiff/next/$p.api - echo - ) >>apidiff/next.txt + apidiff -incompatible tmp/apidiff/$prevversion/$p.api tmp/apidiff/next/$p.api >$p.diff + if test -s $p.diff; then + ( + echo '#' $p + cat $p.diff + echo + ) >>apidiff/next.txt.new + fi + rm $p.diff done +if test -s apidiff/next.txt.new; then + ( + echo "Below are the incompatible changes between $prevversion and next, per package." + echo + cat apidiff/next.txt.new + ) >apidiff/next.txt + rm apidiff/next.txt.new +else + mv apidiff/next.txt.new apidiff/next.txt +fi diff --git a/apidiff/next.txt b/apidiff/next.txt index e69de29..6120436 100644 --- a/apidiff/next.txt +++ b/apidiff/next.txt @@ -0,0 +1,5 @@ +Below are the incompatible changes between v0.0.13 and next, per package. + +# webhook +- PartStructure: removed + diff --git a/queue/hook.go b/queue/hook.go index 700bec4..94fb88d 100644 --- a/queue/hook.go +++ b/queue/hook.go @@ -3,6 +3,7 @@ package queue import ( "context" "encoding/json" + "errors" "fmt" "io" "log/slog" @@ -796,7 +797,7 @@ 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) + structure, err := PartStructure(log, &part) if err != nil { return fmt.Errorf("parsing part structure: %v", err) } @@ -912,6 +913,38 @@ func Incoming(ctx context.Context, log mlog.Log, acc *store.Account, messageID s return nil } +// PartStructure returns a webhook.Structure for a parsed message part. +func PartStructure(log mlog.Log, p *message.Part) (webhook.Structure, error) { + parts := make([]webhook.Structure, len(p.Parts)) + for i := range p.Parts { + var err error + parts[i], err = PartStructure(log, &p.Parts[i]) + if err != nil && !errors.Is(err, message.ErrParamEncoding) { + return webhook.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 webhook.Structure{}, err + } + s := webhook.Structure{ + 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, nil +} + func isAutomated(h textproto.MIMEHeader) bool { l := []string{"List-Id", "List-Unsubscribe", "List-Unsubscribe-Post", "Precedence"} for _, k := range l { diff --git a/queue/hook_test.go b/queue/hook_test.go index 75cebd1..b1190c5 100644 --- a/queue/hook_test.go +++ b/queue/hook_test.go @@ -82,7 +82,7 @@ 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) + structure, err := PartStructure(pkglog, &part) tcheck(t, err, "part structure") expIncoming := webhook.Incoming{ diff --git a/webapisrv/server.go b/webapisrv/server.go index 8505ba9..8e53198 100644 --- a/webapisrv/server.go +++ b/webapisrv/server.go @@ -44,7 +44,6 @@ import ( "github.com/mjl-/mox/store" "github.com/mjl-/mox/webapi" "github.com/mjl-/mox/webauth" - "github.com/mjl-/mox/webhook" "github.com/mjl-/mox/webops" ) @@ -1263,7 +1262,7 @@ func (s server) MessageGet(ctx context.Context, req webapi.MessageGetRequest) (r MailboxName: mb.Name, } - structure, err := webhook.PartStructure(log, &p) + structure, err := queue.PartStructure(log, &p) xcheckf(err, "parsing structure") result := webapi.MessageGetResult{ diff --git a/webapisrv/server_test.go b/webapisrv/server_test.go index 995accb..3e507c5 100644 --- a/webapisrv/server_test.go +++ b/webapisrv/server_test.go @@ -25,7 +25,6 @@ import ( "github.com/mjl-/mox/queue" "github.com/mjl-/mox/store" "github.com/mjl-/mox/webapi" - "github.com/mjl-/mox/webhook" ) var ctxbg = context.Background() @@ -418,7 +417,7 @@ 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") - structure, err := webhook.PartStructure(log, &part) + structure, err := queue.PartStructure(log, &part) tcheckf(t, err, "part structure") tcompare(t, structure, msgRes.Structure) diff --git a/webhook/webhook.go b/webhook/webhook.go index 9582c55..bda6249 100644 --- a/webhook/webhook.go +++ b/webhook/webhook.go @@ -8,12 +8,7 @@ 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 @@ -145,35 +140,3 @@ type Structure struct { 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(log mlog.Log, p *message.Part) (Structure, error) { - parts := make([]Structure, len(p.Parts)) - for i := range p.Parts { - 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, - 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, nil -} From 17baf9a8830c92a669299649c111d420c655ff1e Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Sat, 7 Dec 2024 16:53:53 +0100 Subject: [PATCH 05/12] junk filter: fix adjusting word counts after train/untrain after seeing some junk messages pass the filter, i investigated word counts in junkfilter.db. i had seen suspicious counts that were just around powers of two. did not make sense at the time. more investigating makes it clear: instead of setting new word counts when updating the junk filter, we were adding the new value to the current value (instead of just setting the new value). so the counts got approximately doubled when being updated. users should retrain the junk filter after this update using the "retrain" subcommand. this also adds logging for the hypothetical case where numbers would get decreased below zero (which would wrap around due to uints). and this fixes junk filter tests that were passing wrong parameters to train/untrain... --- ctl.go | 2 ++ junk/filter.go | 22 +++++++++++++++++----- junk/filter_test.go | 8 ++++---- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/ctl.go b/ctl.go index 0efeb2a..81f73e4 100644 --- a/ctl.go +++ b/ctl.go @@ -1348,6 +1348,8 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) { } }() + // todo: can we retrain an account without holding a write lock? perhaps by writing a junkfilter to a new location, and staying informed of message changes while we go through all messages in the account? + acc.WithWLock(func() { conf, _ := acc.Conf() if conf.JunkFilter == nil { diff --git a/junk/filter.go b/junk/filter.go index 387ea49..d37e53c 100644 --- a/junk/filter.go +++ b/junk/filter.go @@ -298,7 +298,7 @@ func (f *Filter) Save() error { } else if err != nil { return err } - return tx.Update(&wordscore{w, wc.Ham + ham, wc.Spam + spam}) + return tx.Update(&wordscore{w, ham, spam}) } if err := update("-", f.hams, f.spams); err != nil { return fmt.Errorf("storing total ham/spam message count: %s", err) @@ -621,10 +621,16 @@ func (f *Filter) Untrain(ctx context.Context, ham bool, words map[string]struct{ // Modify the message count. f.modified = true + var fv *uint32 if ham { - f.hams-- + fv = &f.hams } else { - f.spams-- + fv = &f.spams + } + if *fv == 0 { + f.log.Error("attempt to decrease ham/spam message count while already zero", slog.Bool("ham", ham)) + } else { + *fv -= 1 } // Decrease the word counts. @@ -633,10 +639,16 @@ func (f *Filter) Untrain(ctx context.Context, ham bool, words map[string]struct{ if !ok { continue } + var v *uint32 if ham { - c.Ham-- + v = &c.Ham } else { - c.Spam-- + v = &c.Spam + } + if *v == 0 { + f.log.Error("attempt to decrease ham/spam word count while already zero", slog.String("word", w), slog.Bool("ham", ham)) + } else { + *v -= 1 } f.cache[w] = c f.changed[w] = c diff --git a/junk/filter_test.go b/junk/filter_test.go index bb67c0f..6aea0ef 100644 --- a/junk/filter_test.go +++ b/junk/filter_test.go @@ -126,7 +126,7 @@ func TestFilter(t *testing.T) { tcheck(t, err, "train spam message") _, err = spamf.Seek(0, 0) tcheck(t, err, "seek spam message") - err = f.TrainMessage(ctxbg, spamf, spamsize, true) + err = f.TrainMessage(ctxbg, spamf, spamsize, false) tcheck(t, err, "train spam message") if !f.modified { @@ -166,16 +166,16 @@ func TestFilter(t *testing.T) { tcheck(t, err, "untrain ham message") _, err = hamf.Seek(0, 0) tcheck(t, err, "seek ham message") - err = f.UntrainMessage(ctxbg, hamf, spamsize, true) + err = f.UntrainMessage(ctxbg, hamf, hamsize, true) tcheck(t, err, "untrain ham message") _, err = spamf.Seek(0, 0) tcheck(t, err, "seek spam message") - err = f.UntrainMessage(ctxbg, spamf, spamsize, true) + err = f.UntrainMessage(ctxbg, spamf, spamsize, false) tcheck(t, err, "untrain spam message") _, err = spamf.Seek(0, 0) tcheck(t, err, "seek spam message") - err = f.UntrainMessage(ctxbg, spamf, spamsize, true) + err = f.UntrainMessage(ctxbg, spamf, spamsize, false) tcheck(t, err, "untrain spam message") if !f.modified { From 94fb48c2dc022c761725cbb02184e151e04f4a8d Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Sat, 7 Dec 2024 17:00:00 +0100 Subject: [PATCH 06/12] mox retrain: make the parameter, for account, optional and retrain all accounts when absent for more easily retraining all accounts. users should be retraining their accounts with the next release, due to the fix in the previous commit. --- ctl.go | 115 +++++++++++++++++++++++++++++++------------------------- doc.go | 6 +-- main.go | 12 ++++-- 3 files changed, 74 insertions(+), 59 deletions(-) diff --git a/ctl.go b/ctl.go index 81f73e4..19ba7a8 100644 --- a/ctl.go +++ b/ctl.go @@ -1335,67 +1335,78 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) { case "retrain": /* protocol: > "retrain" - > account + > account or empty < "ok" or error */ account := ctl.xread() - acc, err := store.OpenAccount(log, account) - ctl.xcheck(err, "open account") - defer func() { - if acc != nil { - err := acc.Close() - log.Check(err, "closing account after retraining") - } - }() - // todo: can we retrain an account without holding a write lock? perhaps by writing a junkfilter to a new location, and staying informed of message changes while we go through all messages in the account? - - acc.WithWLock(func() { - conf, _ := acc.Conf() - if conf.JunkFilter == nil { - ctl.xcheck(store.ErrNoJunkFilter, "looking for junk filter") - } - - // Remove existing junk filter files. - basePath := mox.DataDirPath("accounts") - dbPath := filepath.Join(basePath, acc.Name, "junkfilter.db") - bloomPath := filepath.Join(basePath, acc.Name, "junkfilter.bloom") - err := os.Remove(dbPath) - log.Check(err, "removing old junkfilter database file", slog.String("path", dbPath)) - err = os.Remove(bloomPath) - log.Check(err, "removing old junkfilter bloom filter file", slog.String("path", bloomPath)) - - // Open junk filter, this creates new files. - jf, _, err := acc.OpenJunkFilter(ctx, log) - ctl.xcheck(err, "open new junk filter") + xretrain := func(name string) { + acc, err := store.OpenAccount(log, name) + ctl.xcheck(err, "open account") defer func() { - if jf == nil { - return + if acc != nil { + err := acc.Close() + log.Check(err, "closing account after retraining") } - err := jf.Close() - log.Check(err, "closing junk filter during cleanup") }() - // Read through messages with junk or nonjunk flag set, and train them. - var total, trained int - q := bstore.QueryDB[store.Message](ctx, acc.DB) - q.FilterEqual("Expunged", false) - err = q.ForEach(func(m store.Message) error { - total++ - ok, err := acc.TrainMessage(ctx, log, jf, m) - if ok { - trained++ - } - return err - }) - ctl.xcheck(err, "training messages") - log.Info("retrained messages", slog.Int("total", total), slog.Int("trained", trained)) + // todo: can we retrain an account without holding a write lock? perhaps by writing a junkfilter to a new location, and staying informed of message changes while we go through all messages in the account? - // Close junk filter, marking success. - err = jf.Close() - jf = nil - ctl.xcheck(err, "closing junk filter") - }) + acc.WithWLock(func() { + conf, _ := acc.Conf() + if conf.JunkFilter == nil { + ctl.xcheck(store.ErrNoJunkFilter, "looking for junk filter") + } + + // Remove existing junk filter files. + basePath := mox.DataDirPath("accounts") + dbPath := filepath.Join(basePath, acc.Name, "junkfilter.db") + bloomPath := filepath.Join(basePath, acc.Name, "junkfilter.bloom") + err := os.Remove(dbPath) + log.Check(err, "removing old junkfilter database file", slog.String("path", dbPath)) + err = os.Remove(bloomPath) + log.Check(err, "removing old junkfilter bloom filter file", slog.String("path", bloomPath)) + + // Open junk filter, this creates new files. + jf, _, err := acc.OpenJunkFilter(ctx, log) + ctl.xcheck(err, "open new junk filter") + defer func() { + if jf == nil { + return + } + err := jf.Close() + log.Check(err, "closing junk filter during cleanup") + }() + + // Read through messages with junk or nonjunk flag set, and train them. + var total, trained int + q := bstore.QueryDB[store.Message](ctx, acc.DB) + q.FilterEqual("Expunged", false) + err = q.ForEach(func(m store.Message) error { + total++ + ok, err := acc.TrainMessage(ctx, log, jf, m) + if ok { + trained++ + } + return err + }) + ctl.xcheck(err, "training messages") + log.Info("retrained messages", slog.Int("total", total), slog.Int("trained", trained)) + + // Close junk filter, marking success. + err = jf.Close() + jf = nil + ctl.xcheck(err, "closing junk filter") + }) + } + + if account == "" { + for _, name := range mox.Conf.Accounts() { + xretrain(name) + } + } else { + xretrain(account) + } ctl.xwriteok() case "recalculatemailboxcounts": diff --git a/doc.go b/doc.go index cc26287..ce2b225 100644 --- a/doc.go +++ b/doc.go @@ -105,7 +105,7 @@ any parameters. Followed by the help and usage information for each command. mox dnsbl check zone ip mox dnsbl checkhealth zone mox mtasts lookup domain - mox retrain accountname + mox retrain [accountname] mox sendmail [-Fname] [ignoredflags] [-t] [ 1 { c.Usage() } + var account string + if len(args) == 1 { + account = args[0] + } mustLoadConfig() - ctlcmdRetrain(xctl(), args[0]) + ctlcmdRetrain(xctl(), account) } func ctlcmdRetrain(ctl *ctl, account string) { From f7b58c87b1887cac1bf9469a2198736ce519c52b Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Sat, 7 Dec 2024 19:06:16 +0100 Subject: [PATCH 07/12] instead of using loglevel error for printing a warning, just log it as "warn" error level, and don't log message parsing errors as loglevel error --- autotls/autotls.go | 4 ++-- mox-/config.go | 2 +- store/train.go | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/autotls/autotls.go b/autotls/autotls.go index 4bbc229..fa42ad6 100644 --- a/autotls/autotls.go +++ b/autotls/autotls.go @@ -312,12 +312,12 @@ func (m *Manager) SetAllowedHostnames(log mlog.Log, resolver dns.Resolver, hostn for _, h := range added { ips, _, err := resolver.LookupIP(ctx, "ip", h.ASCII+".") if err != nil { - log.Errorx("warning: acme tls cert validation for host may fail due to dns lookup error", err, slog.Any("host", h)) + log.Warnx("acme tls cert validation for host may fail due to dns lookup error", err, slog.Any("host", h)) continue } for _, ip := range ips { if _, ok := publicIPstrs[ip.String()]; !ok { - log.Error("warning: acme tls cert validation for host is likely to fail because not all its ips are being listened on", + log.Warn("acme tls cert validation for host is likely to fail because not all its ips are being listened on", slog.Any("hostname", h), slog.Any("listenedips", publicIPs), slog.Any("hostips", ips), diff --git a/mox-/config.go b/mox-/config.go index 826aa16..34c033d 100644 --- a/mox-/config.go +++ b/mox-/config.go @@ -783,7 +783,7 @@ func PrepareStaticConfig(ctx context.Context, log mlog.Log, configFile string, c } } if l.TLS.ACME != "" && (len(l.TLS.HostPrivateRSA2048Keys) == 0) != (len(l.TLS.HostPrivateECDSAP256Keys) == 0) { - log.Error("warning: uncommon configuration with either only an RSA 2048 or ECDSA P256 host private key for DANE/ACME certificates; this ACME implementation can retrieve certificates for both type of keys, it is recommended to set either both or none; continuing") + log.Warn("uncommon configuration with either only an RSA 2048 or ECDSA P256 host private key for DANE/ACME certificates; this ACME implementation can retrieve certificates for both type of keys, it is recommended to set either both or none; continuing") } // TLS 1.2 was introduced in 2008. TLS <1.2 was deprecated by ../rfc/8996:31 and ../rfc/8997:66 in 2021. diff --git a/store/train.go b/store/train.go index 48cb5b3..8064e92 100644 --- a/store/train.go +++ b/store/train.go @@ -117,7 +117,7 @@ func (a *Account) RetrainMessage(ctx context.Context, log mlog.Log, tx *bstore.T words, err := jf.ParseMessage(p) if err != nil { - log.Errorx("parsing message for updating junk filter", err, slog.Any("parse", "")) + log.Infox("parsing message for updating junk filter", err, slog.Any("parse", "")) return nil } @@ -162,7 +162,7 @@ func (a *Account) TrainMessage(ctx context.Context, log mlog.Log, jf *junk.Filte words, err := jf.ParseMessage(p) if err != nil { - log.Errorx("parsing message for updating junk filter", err, slog.Any("parse", "")) + log.Infox("parsing message for updating junk filter", err, slog.Any("parse", "")) return false, nil } From cbe418ec593442cc6d59707b6831b1f4a8070dbf Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Sat, 7 Dec 2024 19:10:02 +0100 Subject: [PATCH 08/12] try clarifying that aliases are lists, not to be used for simply adding an address to an account for issue #244 by exander77 --- doc.go | 18 +++++++++++------- main.go | 19 ++++++++++++------- webadmin/admin.js | 12 ++++++++---- webadmin/admin.ts | 13 +++++++++---- 4 files changed, 40 insertions(+), 22 deletions(-) diff --git a/doc.go b/doc.go index ce2b225..cb6d1b5 100644 --- a/doc.go +++ b/doc.go @@ -1056,25 +1056,29 @@ error too, for reference. # mox config alias list -List aliases for domain. +Show aliases (lists) for domain. usage: mox config alias list domain # mox config alias print -Print settings and members of alias. +Print settings and members of alias (list). usage: mox config alias print alias # mox config alias add -Add new alias with one or more addresses and public posting enabled. +Add new alias (list) with one or more addresses and public posting enabled. + +An alias is used for delivering incoming email to multiple recipients. If you +want to add an address to an account, don't use an alias, just add the address +to the account. usage: mox config alias add alias@domain rcpt1@domain ... # mox config alias update -Update alias configuration. +Update alias (list) configuration. usage: mox config alias update alias@domain [-postpublic false|true -listmembers false|true -allowmsgfrom false|true] -allowmsgfrom string @@ -1086,19 +1090,19 @@ Update alias configuration. # mox config alias rm -Remove alias. +Remove alias (list). usage: mox config alias rm alias@domain # mox config alias addaddr -Add addresses to alias. +Add addresses to alias (list). usage: mox config alias addaddr alias@domain rcpt1@domain ... # mox config alias rmaddr -Remove addresses from alias. +Remove addresses from alias (list). usage: mox config alias rmaddr alias@domain rcpt1@domain ... diff --git a/main.go b/main.go index 57c586e..2a62f4f 100644 --- a/main.go +++ b/main.go @@ -737,7 +737,7 @@ func ctlcmdConfigDomainRemove(ctl *ctl, d dns.Domain) { func cmdConfigAliasList(c *cmd) { c.params = "domain" - c.help = `List aliases for domain.` + c.help = `Show aliases (lists) for domain.` args := c.Parse() if len(args) != 1 { c.Usage() @@ -756,7 +756,7 @@ func ctlcmdConfigAliasList(ctl *ctl, address string) { func cmdConfigAliasPrint(c *cmd) { c.params = "alias" - c.help = `Print settings and members of alias.` + c.help = `Print settings and members of alias (list).` args := c.Parse() if len(args) != 1 { c.Usage() @@ -775,7 +775,12 @@ func ctlcmdConfigAliasPrint(ctl *ctl, address string) { func cmdConfigAliasAdd(c *cmd) { c.params = "alias@domain rcpt1@domain ..." - c.help = `Add new alias with one or more addresses and public posting enabled.` + c.help = `Add new alias (list) with one or more addresses and public posting enabled. + +An alias is used for delivering incoming email to multiple recipients. If you +want to add an address to an account, don't use an alias, just add the address +to the account. +` args := c.Parse() if len(args) < 2 { c.Usage() @@ -796,7 +801,7 @@ func ctlcmdConfigAliasAdd(ctl *ctl, address string, alias config.Alias) { func cmdConfigAliasUpdate(c *cmd) { c.params = "alias@domain [-postpublic false|true -listmembers false|true -allowmsgfrom false|true]" - c.help = `Update alias configuration.` + c.help = `Update alias (list) configuration.` var postpublic, listmembers, allowmsgfrom string c.flag.StringVar(&postpublic, "postpublic", "", "whether anyone or only list members can post") c.flag.StringVar(&listmembers, "listmembers", "", "whether list members can list members") @@ -822,7 +827,7 @@ func ctlcmdConfigAliasUpdate(ctl *ctl, alias, postpublic, listmembers, allowmsgf func cmdConfigAliasRemove(c *cmd) { c.params = "alias@domain" - c.help = "Remove alias." + c.help = "Remove alias (list)." args := c.Parse() if len(args) != 1 { c.Usage() @@ -840,7 +845,7 @@ func ctlcmdConfigAliasRemove(ctl *ctl, alias string) { func cmdConfigAliasAddaddr(c *cmd) { c.params = "alias@domain rcpt1@domain ..." - c.help = `Add addresses to alias.` + c.help = `Add addresses to alias (list).` args := c.Parse() if len(args) < 2 { c.Usage() @@ -859,7 +864,7 @@ func ctlcmdConfigAliasAddaddr(ctl *ctl, alias string, addresses []string) { func cmdConfigAliasRemoveaddr(c *cmd) { c.params = "alias@domain rcpt1@domain ..." - c.help = `Remove addresses from alias.` + c.help = `Remove addresses from alias (list).` args := c.Parse() if len(args) < 2 { c.Usage() diff --git a/webadmin/admin.js b/webadmin/admin.js index 0435e66..fb9e5a4 100644 --- a/webadmin/admin.js +++ b/webadmin/admin.js @@ -2175,7 +2175,7 @@ const account = async (name) => { await check(fieldset, client.AddressAdd(address, name)); form.reset(); window.location.reload(); // todo: only reload the destinations - }, fieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), dom.span('Localpart', attr.title('The localpart is the part before the "@"-sign of an email address. If empty, a catchall address is configured for the domain.')), dom.br(), localpart = dom.input()), '@', dom.label(style({ display: 'inline-block' }), dom.span('Domain'), dom.br(), domain = dom.select((domains || []).map(d => dom.option(domainName(d), domainName(d) === config.Domain ? attr.selected('') : [])))), ' ', dom.submitbutton('Add address'))), dom.br(), dom.h2('Aliases/lists'), dom.table(dom.thead(dom.tr(dom.th('Alias address', attr.title('Messages sent to this address will be delivered to all members of the alias/list. A member does not receive a message if their address is in the message From header.')), dom.th('Subscription address'), dom.th('Allowed senders', attr.title('Whether only members can send through the alias/list, or anyone.')), dom.th('Send as alias address', attr.title('If enabled, messages can be sent with the alias address in the message "From" header.')), dom.th('Members visible', attr.title('If enabled, members can see the addresses of other members.')))), (config.Aliases || []).length === 0 ? dom.tr(dom.td(attr.colspan('6'), 'None')) : [], (config.Aliases || []).sort((a, b) => a.Alias.LocalpartStr < b.Alias.LocalpartStr ? -1 : (domainName(a.Alias.Domain) < domainName(b.Alias.Domain) ? -1 : 1)).map(a => dom.tr(dom.td(dom.a(prewrap(a.Alias.LocalpartStr, '@', domainName(a.Alias.Domain)), attr.href('#domains/' + domainName(a.Alias.Domain) + '/alias/' + encodeURIComponent(a.Alias.LocalpartStr)))), dom.td(prewrap(a.SubscriptionAddress)), dom.td(a.Alias.PostPublic ? 'Anyone' : 'Members only'), dom.td(a.Alias.AllowMsgFrom ? 'Yes' : 'No'), dom.td(a.Alias.ListMembers ? 'Yes' : 'No'), dom.td(dom.clickbutton('Remove', async function click(e) { + }, fieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), dom.span('Localpart', attr.title('The localpart is the part before the "@"-sign of an email address. If empty, a catchall address is configured for the domain.')), dom.br(), localpart = dom.input()), '@', dom.label(style({ display: 'inline-block' }), dom.span('Domain'), dom.br(), domain = dom.select((domains || []).map(d => dom.option(domainName(d), domainName(d) === config.Domain ? attr.selected('') : [])))), ' ', dom.submitbutton('Add address'))), dom.br(), dom.h2('Alias (list) membership'), dom.table(dom.thead(dom.tr(dom.th('Alias address', attr.title('Messages sent to this address will be delivered to all members of the alias/list. A member does not receive a message if their address is in the message From header.')), dom.th('Subscription address'), dom.th('Allowed senders', attr.title('Whether only members can send through the alias/list, or anyone.')), dom.th('Send as alias address', attr.title('If enabled, messages can be sent with the alias address in the message "From" header.')), dom.th('Members visible', attr.title('If enabled, members can see the addresses of other members.')))), (config.Aliases || []).length === 0 ? dom.tr(dom.td(attr.colspan('6'), 'None')) : [], (config.Aliases || []).sort((a, b) => a.Alias.LocalpartStr < b.Alias.LocalpartStr ? -1 : (domainName(a.Alias.Domain) < domainName(b.Alias.Domain) ? -1 : 1)).map(a => dom.tr(dom.td(dom.a(prewrap(a.Alias.LocalpartStr, '@', domainName(a.Alias.Domain)), attr.href('#domains/' + domainName(a.Alias.Domain) + '/alias/' + encodeURIComponent(a.Alias.LocalpartStr)))), dom.td(prewrap(a.SubscriptionAddress)), dom.td(a.Alias.PostPublic ? 'Anyone' : 'Members only'), dom.td(a.Alias.AllowMsgFrom ? 'Yes' : 'No'), dom.td(a.Alias.ListMembers ? 'Yes' : 'No'), dom.td(dom.clickbutton('Remove', async function click(e) { await check(e.target, client.AliasAddressesRemove(a.Alias.LocalpartStr, domainName(a.Alias.Domain), [a.SubscriptionAddress])); window.location.reload(); // todo: reload less }))))), dom.br(), dom.h2('Settings'), dom.form(fieldsetSettings = dom.fieldset(dom.label(style({ display: 'block', marginBottom: '.5ex' }), dom.span('Maximum outgoing messages per day', attr.title('Maximum number of outgoing messages for this account in a 24 hour window. This limits the damage to recipients and the reputation of this mail server in case of account compromise. Default 1000. MaxOutgoingMessagesPerDay in configuration file.')), dom.br(), maxOutgoingMessagesPerDay = dom.input(attr.type('number'), attr.required(''), attr.value('' + (config.MaxOutgoingMessagesPerDay || 1000)))), dom.label(style({ display: 'block', marginBottom: '.5ex' }), dom.span('Maximum first-time recipients per day', attr.title('Maximum number of first-time recipients in outgoing messages for this account in a 24 hour window. This limits the damage to recipients and the reputation of this mail server in case of account compromise. Default 200. MaxFirstTimeRecipientsPerDay in configuration file.')), dom.br(), maxFirstTimeRecipientsPerDay = dom.input(attr.type('number'), attr.required(''), attr.value('' + (config.MaxFirstTimeRecipientsPerDay || 200)))), dom.label(style({ display: 'block', marginBottom: '.5ex' }), dom.span('Disk usage quota: Maximum total message size ', attr.title('Default maximum total message size in bytes for the account, overriding any globally configured default maximum size if non-zero. A negative value can be used to have no limit in case there is a limit by default. Attempting to add new messages to an account beyond its maximum total size will result in an error. Useful to prevent a single account from filling storage. Use units "k" for kilobytes, or "m", "g", "t".')), dom.br(), quotaMessageSize = dom.input(attr.value(formatQuotaSize(config.QuotaMessageSize))), ' Current usage is ', formatQuotaSize(Math.floor(diskUsage / (1024 * 1024)) * 1024 * 1024), '.'), dom.div(style({ display: 'block', marginBottom: '.5ex' }), dom.label(firstTimeSenderDelay = dom.input(attr.type('checkbox'), config.NoFirstTimeSenderDelay ? [] : attr.checked('')), ' ', dom.span('Delay deliveries from first-time senders.', attr.title('To slow down potential spammers, when the message is misclassified as non-junk. Turning off the delay can be useful when the account processes messages automatically and needs fast responses.')))), dom.submitbutton('Save')), async function submit(e) { @@ -2289,6 +2289,7 @@ const domain = async (d) => { let aliasFieldset; let aliasLocalpart; let aliasAddresses; + let aliasAddText; let descrFieldset; let descrText; let clientSettingsDomainFieldset; @@ -2364,9 +2365,9 @@ const domain = async (d) => { await check(addrFieldset, client.AddressAdd(addrLocalpart.value + '@' + d, addrAccount.value)); addrForm.reset(); window.location.reload(); // todo: only reload the addresses - }, addrFieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), dom.span('Localpart', attr.title('The localpart is the part before the "@"-sign of an address. An empty localpart is the catchall destination/address for the domain.')), dom.br(), addrLocalpart = dom.input()), '@', domainName(dnsdomain), ' ', dom.label(style({ display: 'inline-block' }), dom.span('Account', attr.title('Account to assign the address to.')), dom.br(), addrAccount = dom.select(attr.required(''), (accounts || []).map(a => dom.option(a)))), ' ', dom.submitbutton('Add address', attr.title('Address will be added and the config reloaded.')))), dom.br(), dom.h2('Aliases/lists'), dom.table(dom.thead(dom.tr(dom.th('Address'), dom.th('Allowed senders', attr.title('Whether only members can send through the alias/list, or anyone.')), dom.th('Send as alias address', attr.title('If enabled, messages can be sent with the alias address in the message "From" header.')), dom.th('Members visible', attr.title('If enabled, members can see the addresses of other members.')))), Object.values(localpartAliases).length === 0 ? dom.tr(dom.td(attr.colspan('4'), 'None')) : [], Object.values(localpartAliases).sort((a, b) => a.LocalpartStr < b.LocalpartStr ? -1 : 1).map(a => { + }, addrFieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), dom.span('Localpart', attr.title('The localpart is the part before the "@"-sign of an address. An empty localpart is the catchall destination/address for the domain.')), dom.br(), addrLocalpart = dom.input()), '@', domainName(dnsdomain), ' ', dom.label(style({ display: 'inline-block' }), dom.span('Account', attr.title('Account to assign the address to.')), dom.br(), addrAccount = dom.select(attr.required(''), (accounts || []).map(a => dom.option(a)))), ' ', dom.submitbutton('Add address', attr.title('Address will be added and the config reloaded.')))), dom.br(), dom.h2('Aliases (lists)'), dom.table(dom.thead(dom.tr(dom.th('Address'), dom.th('Allowed senders', attr.title('Whether only members can send through the alias/list, or anyone.')), dom.th('Send as alias address', attr.title('If enabled, messages can be sent with the alias address in the message "From" header.')), dom.th('Members visible', attr.title('If enabled, members can see the addresses of other members.')))), Object.values(localpartAliases).length === 0 ? dom.tr(dom.td(attr.colspan('4'), 'None')) : [], Object.values(localpartAliases).sort((a, b) => a.LocalpartStr < b.LocalpartStr ? -1 : 1).map(a => { return dom.tr(dom.td(dom.a(prewrap(a.LocalpartStr), attr.href('#domains/' + d + '/alias/' + encodeURIComponent(a.LocalpartStr)))), dom.td(a.PostPublic ? 'Anyone' : 'Members only'), dom.td(a.AllowMsgFrom ? 'Yes' : 'No'), dom.td(a.ListMembers ? 'Yes' : 'No')); - })), dom.br(), dom.h2('Add alias'), dom.form(async function submit(e) { + })), dom.br(), dom.h2('Add alias (list)'), dom.form(async function submit(e) { e.preventDefault(); e.stopPropagation(); const alias = { @@ -2380,7 +2381,10 @@ const domain = async (d) => { }; await check(aliasFieldset, client.AliasAdd(aliasLocalpart.value, d, alias)); window.location.hash = '#domains/' + d + '/alias/' + encodeURIComponent(aliasLocalpart.value); - }, aliasFieldset = dom.fieldset(style({ display: 'flex', alignItems: 'flex-start', gap: '1em' }), dom.label(dom.div('Localpart', attr.title('The localpart is the part before the "@"-sign of an address.')), aliasLocalpart = dom.input(attr.required('')), '@', domainName(dnsdomain), ' '), dom.label(dom.div('Addresses', attr.title('One members address per line, full address of form localpart@domain. At least one address required.')), aliasAddresses = dom.textarea(attr.required(''), attr.rows('1'), function focus() { aliasAddresses.setAttribute('rows', '5'); })), dom.div(dom.div('\u00a0'), dom.submitbutton('Add alias', attr.title('Alias will be added and the config reloaded.'))))), dom.br(), RoutesEditor('domain-specific', transports, domainConfig.Routes || [], async (routes) => await client.DomainRoutesSave(d, routes)), dom.br(), dom.h2('Settings'), dom.form(async function submit(e) { + }, aliasFieldset = dom.fieldset(style({ display: 'flex', alignItems: 'flex-start', gap: '1em' }), dom.label(dom.div('Localpart', attr.title('The localpart is the part before the "@"-sign of an address.')), aliasLocalpart = dom.input(attr.required('')), '@', domainName(dnsdomain), ' '), dom.label(dom.div('Addresses', attr.title('One members address per line, full address of form localpart@domain. At least one address required.')), aliasAddresses = dom.textarea(attr.required(''), attr.rows('1'), function focus() { + aliasAddresses.setAttribute('rows', '5'); + aliasAddText.style.visibility = 'visible'; + })), dom.div(dom.div('\u00a0'), dom.submitbutton('Add alias', attr.title('Alias will be added and the config reloaded.')), aliasAddText = dom.p(style({ visibility: 'hidden', fontStyle: 'italic' }), 'Messages sent to aliases are delivered to each member address of the alias, like a mailing list. For an additional address for an account, add it as regular address (see above).')))), dom.br(), RoutesEditor('domain-specific', transports, domainConfig.Routes || [], async (routes) => await client.DomainRoutesSave(d, routes)), dom.br(), dom.h2('Settings'), dom.form(async function submit(e) { e.preventDefault(); e.stopPropagation(); await check(descrFieldset, client.DomainDescriptionSave(d, descrText.value)); diff --git a/webadmin/admin.ts b/webadmin/admin.ts index 8fdb278..b7133e5 100644 --- a/webadmin/admin.ts +++ b/webadmin/admin.ts @@ -884,7 +884,7 @@ const account = async (name: string) => { ), dom.br(), - dom.h2('Aliases/lists'), + dom.h2('Alias (list) membership'), dom.table( dom.thead( dom.tr( @@ -1094,6 +1094,7 @@ const domain = async (d: string) => { let aliasFieldset: HTMLFieldSetElement let aliasLocalpart: HTMLInputElement let aliasAddresses: HTMLTextAreaElement + let aliasAddText: HTMLElement let descrFieldset: HTMLFieldSetElement let descrText: HTMLInputElement @@ -1347,7 +1348,7 @@ const domain = async (d: string) => { ), dom.br(), - dom.h2('Aliases/lists'), + dom.h2('Aliases (lists)'), dom.table( dom.thead( dom.tr( @@ -1368,7 +1369,7 @@ const domain = async (d: string) => { }), ), dom.br(), - dom.h2('Add alias'), + dom.h2('Add alias (list)'), dom.form( async function submit(e: SubmitEvent) { e.preventDefault() @@ -1395,11 +1396,15 @@ const domain = async (d: string) => { ), dom.label( dom.div('Addresses', attr.title('One members address per line, full address of form localpart@domain. At least one address required.')), - aliasAddresses=dom.textarea(attr.required(''), attr.rows('1'), function focus() { aliasAddresses.setAttribute('rows', '5') }), + aliasAddresses=dom.textarea(attr.required(''), attr.rows('1'), function focus() { + aliasAddresses.setAttribute('rows', '5') + aliasAddText.style.visibility = 'visible' + }), ), dom.div( dom.div('\u00a0'), dom.submitbutton('Add alias', attr.title('Alias will be added and the config reloaded.')), + aliasAddText=dom.p(style({visibility: 'hidden', fontStyle: 'italic'}), 'Messages sent to aliases are delivered to each member address of the alias, like a mailing list. For an additional address for an account, add it as regular address (see above).'), ), ), ), From 35af7e30a6ad3c20a09692d9c568cd7fe4caac50 Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Sat, 7 Dec 2024 20:28:52 +0100 Subject: [PATCH 09/12] do not try to get a tls cert for autoconfig. at startup if there is no listener with autoconfig enabled reduces needless logging in setups that don't use autoconfig. --- http/web.go | 44 +++++++++++++++++++++++++------------------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/http/web.go b/http/web.go index 81fa97b..447a2d7 100644 --- a/http/web.go +++ b/http/web.go @@ -821,32 +821,38 @@ func portServes(l config.Listener) map[int]*serve { } if l.TLS != nil && l.TLS.ACME != "" { - hosts := map[dns.Domain]struct{}{ - mox.Conf.Static.HostnameDomain: {}, + m := mox.Conf.Static.ACME[l.TLS.ACME].Manager + if ensureManagerHosts[m] == nil { + ensureManagerHosts[m] = map[dns.Domain]struct{}{} } + hosts := ensureManagerHosts[m] + hosts[mox.Conf.Static.HostnameDomain] = struct{}{} + if l.HostnameDomain.ASCII != "" { hosts[l.HostnameDomain] = struct{}{} } - // All domains are served on all listeners. Gather autoconfig hostnames to ensure - // presence of TLS certificates for. - for _, name := range mox.Conf.Domains() { - if dom, err := dns.ParseDomain(name); err != nil { - pkglog.Errorx("parsing domain from config", err) - } else if d, _ := mox.Conf.Domain(dom); d.ReportsOnly { - // Do not gather autoconfig name if we aren't accepting email for this domain. - continue - } - autoconfdom, err := dns.ParseDomain("autoconfig." + name) - if err != nil { - pkglog.Errorx("parsing domain from config for autoconfig", err) - } else { - hosts[autoconfdom] = struct{}{} + // All domains are served on all listeners. Gather autoconfig hostnames to ensure + // presence of TLS certificates. Fetching a certificate on-demand may be too slow + // for the timeouts of clients doing autoconfig. + + if l.AutoconfigHTTPS.Enabled && !l.AutoconfigHTTPS.NonTLS { + for _, name := range mox.Conf.Domains() { + if dom, err := dns.ParseDomain(name); err != nil { + pkglog.Errorx("parsing domain from config", err) + } else if d, _ := mox.Conf.Domain(dom); d.ReportsOnly { + // Do not gather autoconfig name if we aren't accepting email for this domain. + continue + } + + autoconfdom, err := dns.ParseDomain("autoconfig." + name) + if err != nil { + pkglog.Errorx("parsing domain from config for autoconfig", err) + } else { + hosts[autoconfdom] = struct{}{} + } } } - - m := mox.Conf.Static.ACME[l.TLS.ACME].Manager - ensureManagerHosts[m] = hosts } for _, srv := range portServe { From 2255ebcf11869c936c2c49db7527e12a5cf8003e Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Sat, 7 Dec 2024 21:14:43 +0100 Subject: [PATCH 10/12] quickstart: write all output to a file "quickstart.log" for later reference quite some output is printed. you could remember to tee it all to a file. but that's probably often realized only after having run the quickstart. you can also copy/paste it all from the terminal, but that's sometimes annoying to do. writing to a file is more helpful to users. this has been requested a few times in the past on irc/matrix (i forgot who). --- doc.go | 2 ++ quickstart.go | 36 +++++++++++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/doc.go b/doc.go index cb6d1b5..0761186 100644 --- a/doc.go +++ b/doc.go @@ -146,6 +146,8 @@ Quickstart writes configuration files, prints initial admin and account passwords, DNS records you should create. If you run it on Linux it writes a systemd service file and prints commands to enable and start mox as service. +All output is written to quickstart.log for later reference. + The user or uid is optional, defaults to "mox", and is the user or uid/gid mox will run as after initialization. diff --git a/quickstart.go b/quickstart.go index 4dc7e26..c82aed2 100644 --- a/quickstart.go +++ b/quickstart.go @@ -12,6 +12,7 @@ import ( "encoding/pem" "errors" "fmt" + "io" "log" "net" "net/url" @@ -67,6 +68,8 @@ Quickstart writes configuration files, prints initial admin and account passwords, DNS records you should create. If you run it on Linux it writes a systemd service file and prints commands to enable and start mox as service. +All output is written to quickstart.log for later reference. + The user or uid is optional, defaults to "mox", and is the user or uid/gid mox will run as after initialization. @@ -105,6 +108,35 @@ output of "mox config describe-domains" and see the output of c.Usage() } + // Write all output to quickstart.log. + logfile, err := os.Create("quickstart.log") + xcheckf(err, "creating quickstart.log") + + origStdout := os.Stdout + origStderr := os.Stderr + piper, pipew, err := os.Pipe() + xcheckf(err, "creating pipe for logging to logfile") + pipec := make(chan struct{}) + go func() { + io.Copy(io.MultiWriter(origStdout, logfile), piper) + close(pipec) + }() + // A single pipe, so writes to stdout and stderr don't get interleaved. + os.Stdout = pipew + os.Stderr = pipew + logClose := func() { + pipew.Close() + <-pipec + os.Stdout = origStdout + os.Stderr = origStderr + err := logfile.Close() + xcheckf(err, "closing quickstart.log") + } + defer logClose() + log.SetOutput(os.Stdout) + fmt.Printf("(output is also written to quickstart.log)\n\n") + defer fmt.Printf("\n(output is also written to quickstart.log)\n") + // We take care to cleanup created files when we error out. // We don't want to get a new user into trouble with half of the files // after encountering an error. @@ -121,7 +153,9 @@ output of "mox config describe-domains" and see the output of } } - log.Fatalf(format, args...) + log.Printf(format, args...) + logClose() + os.Exit(1) } xwritefile := func(path string, data []byte, perm os.FileMode) { From 5320ec1c5bc8aa50d73b3770874f8f10ddbd7349 Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Sun, 8 Dec 2024 10:18:57 +0100 Subject: [PATCH 11/12] quickstart: for -existing-webserver, also tls key/cert placeholder for mail.$domain unless mail.$domain is the mx hostname. after question about which tls certs are needed from robbo5000 on matrix --- quickstart.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/quickstart.go b/quickstart.go index c82aed2..dd4a123 100644 --- a/quickstart.go +++ b/quickstart.go @@ -744,6 +744,7 @@ many authentication failures). hostbase := filepath.FromSlash("path/to/" + dnshostname.Name()) mtastsbase := filepath.FromSlash("path/to/mta-sts." + domain.Name()) autoconfigbase := filepath.FromSlash("path/to/autoconfig." + domain.Name()) + mailbase := filepath.FromSlash("path/to/mail." + domain.Name()) public.TLS = &config.TLS{ KeyCerts: []config.KeyCert{ {CertFile: hostbase + "-chain.crt.pem", KeyFile: hostbase + ".key.pem"}, @@ -751,6 +752,9 @@ many authentication failures). {CertFile: autoconfigbase + "-chain.crt.pem", KeyFile: autoconfigbase + ".key.pem"}, }, } + if mailbase != hostbase { + public.TLS.KeyCerts = append(public.TLS.KeyCerts, config.KeyCert{CertFile: mailbase + "-chain.crt.pem", KeyFile: mailbase + ".key.pem"}) + } fmt.Println( `Placeholder paths to TLS certificates to be provided by the existing webserver From d082aaada8bdc09b847eae2ee3e6ba3086d06a48 Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Sat, 14 Dec 2024 09:38:56 +0100 Subject: [PATCH 12/12] only use constant strings in string formatting builds with go1.24rc1 fail on these. only the case in smtpserver could be triggered externally. --- dkim/sig.go | 4 ++-- imapserver/selectexamine_test.go | 12 ++++++------ imapserver/server.go | 2 +- smtpserver/server.go | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/dkim/sig.go b/dkim/sig.go index 321152b..aed27ee 100644 --- a/dkim/sig.go +++ b/dkim/sig.go @@ -117,7 +117,7 @@ func (s *Sig) Header() (string, error) { } else if i == len(s.SignedHeaders)-1 { v += ";" } - w.Addf(sep, v) + w.Addf(sep, "%s", v) } } if len(s.CopiedHeaders) > 0 { @@ -139,7 +139,7 @@ func (s *Sig) Header() (string, error) { } else if i == len(s.CopiedHeaders)-1 { v += ";" } - w.Addf(sep, v) + w.Addf(sep, "%s", v) } } diff --git a/imapserver/selectexamine_test.go b/imapserver/selectexamine_test.go index e11bbdf..58645d6 100644 --- a/imapserver/selectexamine_test.go +++ b/imapserver/selectexamine_test.go @@ -45,28 +45,28 @@ func testSelectExamine(t *testing.T, examine bool) { uuidnext2 := imapclient.UntaggedResult{Status: imapclient.OK, RespText: imapclient.RespText{Code: "UIDNEXT", CodeArg: imapclient.CodeUint{Code: "UIDNEXT", Num: 2}, More: "x"}} // Parameter required. - tc.transactf("bad", cmd) + tc.transactf("bad", "%s", cmd) // Mailbox does not exist. - tc.transactf("no", cmd+" bogus") + tc.transactf("no", "%s bogus", cmd) - tc.transactf("ok", cmd+" inbox") + tc.transactf("ok", "%s inbox", cmd) tc.xuntagged(uflags, upermflags, urecent, uexists0, uuidval1, uuidnext1, ulist) tc.xcode(okcode) - tc.transactf("ok", cmd+` "inbox"`) + tc.transactf("ok", `%s "inbox"`, cmd) tc.xuntagged(uclosed, uflags, upermflags, urecent, uexists0, uuidval1, uuidnext1, ulist) tc.xcode(okcode) // Append a message. It will be reported as UNSEEN. tc.client.Append("inbox", nil, nil, []byte(exampleMsg)) - tc.transactf("ok", cmd+" inbox") + tc.transactf("ok", "%s inbox", cmd) tc.xuntagged(uclosed, uflags, upermflags, urecent, uunseen, uexists1, uuidval1, uuidnext2, ulist) tc.xcode(okcode) // With imap4rev2, we no longer get untagged RECENT or untagged UNSEEN. tc.client.Enable("imap4rev2") - tc.transactf("ok", cmd+" inbox") + tc.transactf("ok", "%s inbox", cmd) tc.xuntagged(uclosed, uflags, upermflags, uexists1, uuidval1, uuidnext2, ulist) tc.xcode(okcode) } diff --git a/imapserver/server.go b/imapserver/server.go index 59ad488..8ddebd7 100644 --- a/imapserver/server.go +++ b/imapserver/server.go @@ -1167,7 +1167,7 @@ func (c *conn) xsequence(uid store.UID) msgseq { func (c *conn) sequenceRemove(seq msgseq, uid store.UID) { i := seq - 1 if c.uids[i] != uid { - xserverErrorf(fmt.Sprintf("got uid %d at msgseq %d, expected uid %d", uid, seq, c.uids[i])) + xserverErrorf("got uid %d at msgseq %d, expected uid %d", uid, seq, c.uids[i]) } copy(c.uids[i:], c.uids[i+1:]) c.uids = c.uids[:len(c.uids)-1] diff --git a/smtpserver/server.go b/smtpserver/server.go index 424c88c..629f3ae 100644 --- a/smtpserver/server.go +++ b/smtpserver/server.go @@ -3471,7 +3471,7 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW code = smtp.C554TransactionFailed } lines = append(lines, "multiple errors") - xsmtpErrorf(code, secode, !serverError, strings.Join(lines, "\n")) + xsmtpErrorf(code, secode, !serverError, "%s", strings.Join(lines, "\n")) } // Generate one DSN for all failed recipients. if len(deliverErrors) > 0 {