Merge branch 'main' into tls-alpn-mux

This commit is contained in:
s0ph0s 2024-12-14 14:36:47 -05:00
commit 2db7323921
31 changed files with 493 additions and 370 deletions

View file

@ -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

View file

@ -0,0 +1,5 @@
Below are the incompatible changes between v0.0.13 and next, per package.
# webhook
- PartStructure: removed

View file

@ -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),

113
ctl.go
View file

@ -1335,65 +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")
}
}()
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":

View file

@ -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)
}
}

26
doc.go
View file

@ -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] [<message]
mox spf check domain ip
mox spf lookup domain
@ -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.
@ -1056,25 +1058,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 +1092,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 ...
@ -1390,12 +1396,12 @@ should be used, and how long the policy can be cached.
# mox retrain
Recreate and retrain the junk filter for the account.
Recreate and retrain the junk filter for the account or all accounts.
Useful after having made changes to the junk filter configuration, or if the
implementation has changed.
usage: mox retrain accountname
usage: mox retrain [accountname]
# mox sendmail

View file

@ -834,32 +834,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 {

View file

@ -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)
}

View file

@ -1180,7 +1180,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]

View file

@ -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

View file

@ -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 {

63
main.go
View file

@ -1,7 +1,6 @@
package main
import (
"bufio"
"bytes"
"context"
"crypto"
@ -738,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()
@ -757,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()
@ -776,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()
@ -797,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")
@ -823,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()
@ -841,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()
@ -860,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()
@ -2875,19 +2879,23 @@ should be used, and how long the policy can be cached.
}
func cmdRetrain(c *cmd) {
c.params = "accountname"
c.help = `Recreate and retrain the junk filter for the account.
c.params = "[accountname]"
c.help = `Recreate and retrain the junk filter for the account or all accounts.
Useful after having made changes to the junk filter configuration, or if the
implementation has changed.
`
args := c.Parse()
if len(args) != 1 {
if len(args) > 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) {
@ -3519,35 +3527,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)
}
}

View file

@ -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"

View file

@ -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.

View file

@ -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 {

View file

@ -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{

View file

@ -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) {
@ -710,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"},
@ -717,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

View file

@ -1956,47 +1956,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
@ -3502,7 +3487,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 {

View file

@ -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

View file

@ -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
}

View file

@ -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));

View file

@ -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).'),
),
),
),

View file

@ -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{

View file

@ -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)

View file

@ -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
}

View file

@ -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"
]
}
]
},

View file

@ -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"]}]},

View file

@ -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"] }] },

View file

@ -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"] }] },

View file

@ -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'],
@ -2477,7 +2492,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 +2538,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'],
@ -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
@ -3893,7 +3873,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,
@ -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);
},
};
@ -5380,6 +5361,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 +5409,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);

View file

@ -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'}),
@ -1212,7 +1254,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 +1320,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'],
@ -1294,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')),
),
),
@ -3156,7 +3158,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,
@ -3251,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})
@ -3510,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
@ -3539,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)
},
}
@ -4981,6 +4984,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 +5026,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)