diff --git a/config/config.go b/config/config.go index f76bd5a..2fc1fb5 100644 --- a/config/config.go +++ b/config/config.go @@ -57,7 +57,8 @@ type Static struct { Account string Mailbox string `sconf-doc:"E.g. Postmaster or Inbox."` } `sconf-doc:"Destination for emails delivered to postmaster addresses: a plain 'postmaster' without domain, 'postmaster@' (also for each listener with SMTP enabled), and as fallback for each domain without explicitly configured postmaster destination."` - DefaultMailboxes []string `sconf:"optional" sconf-doc:"Mailboxes to create when adding an account. Inbox is always created. If no mailboxes are specified, the following are automatically created: Sent, Archive, Trash, Drafts and Junk."` + InitialMailboxes InitialMailboxes `sconf:"optional" sconf-doc:"Mailboxes to create for new accounts. Inbox is always created. Mailboxes can be given a 'special-use' role, which are understood by most mail clients. If absent/empty, the following mailboxes are created: Sent, Archive, Trash, Drafts and Junk."` + DefaultMailboxes []string `sconf:"optional" sconf-doc:"Deprecated in favor of InitialMailboxes. Mailboxes to create when adding an account. Inbox is always created. If no mailboxes are specified, the following are automatically created: Sent, Archive, Trash, Drafts and Junk."` Transports map[string]Transport `sconf:"optional" sconf-doc:"Transport are mechanisms for delivering messages. Transports can be referenced from Routes in accounts, domains and the global configuration. There is always an implicit/fallback delivery transport doing direct delivery with SMTP from the outgoing message queue. Transports are typically only configured when using smarthosts, i.e. when delivering through another SMTP server. Zero or one transport methods must be set in a transport, never multiple. When using an external party to send email for a domain, keep in mind you may have to add their IP address to your domain's SPF record, and possibly additional DKIM records."` // All IPs that were explicitly listen on for external SMTP. Only set when there @@ -74,6 +75,23 @@ type Static struct { GID uint32 `sconf:"-" json:"-"` } +// InitialMailboxes are mailboxes created for a new account. +type InitialMailboxes struct { + SpecialUse SpecialUseMailboxes `sconf:"optional" sconf-doc:"Special-use roles to mailbox to create."` + Regular []string `sconf:"optional" sconf-doc:"Regular, non-special-use mailboxes to create."` +} + +// SpecialUseMailboxes holds mailbox names for special-use roles. Mail clients +// recognize these special-use roles, e.g. appending sent messages to whichever +// mailbox has the Sent special-use flag. +type SpecialUseMailboxes struct { + Sent string `sconf:"optional"` + Archive string `sconf:"optional"` + Trash string `sconf:"optional"` + Draft string `sconf:"optional"` + Junk string `sconf:"optional"` +} + // Dynamic is the parsed form of domains.conf, and is automatically reloaded when changed. type Dynamic struct { Domains map[string]Domain `sconf-doc:"Domains for which email is accepted. For internationalized domains, use their IDNA names in UTF-8."` diff --git a/config/doc.go b/config/doc.go index 9111192..e278013 100644 --- a/config/doc.go +++ b/config/doc.go @@ -359,9 +359,37 @@ describe-static" and "mox config describe-domains": # E.g. Postmaster or Inbox. Mailbox: - # Mailboxes to create when adding an account. Inbox is always created. If no - # mailboxes are specified, the following are automatically created: Sent, Archive, - # Trash, Drafts and Junk. (optional) + # Mailboxes to create for new accounts. Inbox is always created. Mailboxes can be + # given a 'special-use' role, which are understood by most mail clients. If + # absent/empty, the following mailboxes are created: Sent, Archive, Trash, Drafts + # and Junk. (optional) + InitialMailboxes: + + # Special-use roles to mailbox to create. (optional) + SpecialUse: + + # (optional) + Sent: + + # (optional) + Archive: + + # (optional) + Trash: + + # (optional) + Draft: + + # (optional) + Junk: + + # Regular, non-special-use mailboxes to create. (optional) + Regular: + - + + # Deprecated in favor of InitialMailboxes. Mailboxes to create when adding an + # account. Inbox is always created. If no mailboxes are specified, the following + # are automatically created: Sent, Archive, Trash, Drafts and Junk. (optional) DefaultMailboxes: - diff --git a/doc.go b/doc.go index f8e93c9..aa80df8 100644 --- a/doc.go +++ b/doc.go @@ -236,9 +236,9 @@ Import a maildir into an account. By default, messages will train the junk filter based on their flags and, if "automatic junk flags" configuration is set, based on mailbox naming. -If the destination mailbox is "Sent", the recipients of the messages are added -to the message metadata, causing later incoming messages from these recipients -to be accepted, unless other reputation signals prevent that. +If the destination mailbox is the Sent mailbox, the recipients of the messages +are added to the message metadata, causing later incoming messages from these +recipients to be accepted, unless other reputation signals prevent that. Users can also import mailboxes/messages through the account web page by uploading a zip or tgz file with mbox and/or maildirs. @@ -260,9 +260,9 @@ Using mbox is not recommended, maildir is a better defined format. By default, messages will train the junk filter based on their flags and, if "automatic junk flags" configuration is set, based on mailbox naming. -If the destination mailbox is "Sent", the recipients of the messages are added -to the message metadata, causing later incoming messages from these recipients -to be accepted, unless other reputation signals prevent that. +If the destination mailbox is the Sent mailbox, the recipients of the messages +are added to the message metadata, causing later incoming messages from these +recipients to be accepted, unless other reputation signals prevent that. Users can also import mailboxes/messages through the account web page by uploading a zip or tgz file with mbox and/or maildirs. diff --git a/gentestdata.go b/gentestdata.go index 1be5f20..16b657f 100644 --- a/gentestdata.go +++ b/gentestdata.go @@ -281,7 +281,7 @@ Accounts: xcheckf(err, "creating temp file for delivery") _, err = fmt.Fprint(mf, msg) xcheckf(err, "writing deliver message to file") - err = accTest1.DeliverMessage(log, tx, &m, mf, true, false, false, true) + err = accTest1.DeliverMessage(log, tx, &m, mf, true, false, true) xcheckf(err, "add message to account test1") err = mf.Close() xcheckf(err, "closing file") @@ -335,7 +335,7 @@ Accounts: xcheckf(err, "creating temp file for delivery") _, err = fmt.Fprint(mf0, msg0) xcheckf(err, "writing deliver message to file") - err = accTest2.DeliverMessage(log, tx, &m0, mf0, true, false, false, false) + err = accTest2.DeliverMessage(log, tx, &m0, mf0, true, false, false) xcheckf(err, "add message to account test2") err = mf0.Close() xcheckf(err, "closing file") @@ -362,7 +362,7 @@ Accounts: xcheckf(err, "creating temp file for delivery") _, err = fmt.Fprint(mf1, msg1) xcheckf(err, "writing deliver message to file") - err = accTest2.DeliverMessage(log, tx, &m1, mf1, true, true, false, false) + err = accTest2.DeliverMessage(log, tx, &m1, mf1, true, false, false) xcheckf(err, "add message to account test2") err = mf1.Close() xcheckf(err, "closing file") diff --git a/imapserver/list_test.go b/imapserver/list_test.go index 4fe1f6b..62a067d 100644 --- a/imapserver/list_test.go +++ b/imapserver/list_test.go @@ -71,7 +71,11 @@ func TestListExtended(t *testing.T) { } uidvals := map[string]uint32{} - for _, name := range store.InitialMailboxes { + use := store.DefaultInitialMailboxes.SpecialUse + for _, name := range []string{"Inbox", use.Archive, use.Draft, use.Junk, use.Sent, use.Trash} { + uidvals[name] = 1 + } + for _, name := range store.DefaultInitialMailboxes.Regular { uidvals[name] = 1 } var uidvalnext uint32 = 2 diff --git a/imapserver/server.go b/imapserver/server.go index 7569f1d..f8b61a8 100644 --- a/imapserver/server.go +++ b/imapserver/server.go @@ -2741,8 +2741,7 @@ func (c *conn) cmdAppend(tag, cmd string, p *parser) { err = tx.Update(&mb) xcheckf(err, "updating mailbox counts") - isSent := name == "Sent" - err := c.account.DeliverMessage(c.log, tx, &m, msgFile, true, isSent, true, false) + err := c.account.DeliverMessage(c.log, tx, &m, msgFile, true, true, false) xcheckf(err, "delivering message") }) diff --git a/import.go b/import.go index 84696e3..5e8f878 100644 --- a/import.go +++ b/import.go @@ -30,9 +30,9 @@ import ( const importCommonHelp = `By default, messages will train the junk filter based on their flags and, if "automatic junk flags" configuration is set, based on mailbox naming. -If the destination mailbox is "Sent", the recipients of the messages are added -to the message metadata, causing later incoming messages from these recipients -to be accepted, unless other reputation signals prevent that. +If the destination mailbox is the Sent mailbox, the recipients of the messages +are added to the message metadata, causing later incoming messages from these +recipients to be accepted, unless other reputation signals prevent that. Users can also import mailboxes/messages through the account web page by uploading a zip or tgz file with mbox and/or maildirs. @@ -275,10 +275,9 @@ func importctl(ctx context.Context, ctl *ctl, mbox bool) { // todo: possibly set dmarcdomain to the domain of the from address? at least for non-spams that have been seen. otherwise user would start without any reputations. the assumption would be that the user has accepted email and deemed it legit, coming from the indicated sender. const consumeFile = true - isSent := mailbox == "Sent" const sync = false const notrain = true - err := a.DeliverMessage(ctl.log, tx, m, mf, consumeFile, isSent, sync, notrain) + err := a.DeliverMessage(ctl.log, tx, m, mf, consumeFile, sync, notrain) ctl.xcheck(err, "delivering message") deliveredIDs = append(deliveredIDs, m.ID) ctl.log.Debug("delivered message", mlog.Field("id", m.ID)) diff --git a/mox-/config.go b/mox-/config.go index 30cd640..71315b3 100644 --- a/mox-/config.go +++ b/mox-/config.go @@ -644,9 +644,33 @@ func PrepareStaticConfig(ctx context.Context, configFile string, conf *Config, c c.SpecifiedSMTPListenIPs = nil } + var zerouse config.SpecialUseMailboxes + if len(c.DefaultMailboxes) > 0 && (c.InitialMailboxes.SpecialUse != zerouse || len(c.InitialMailboxes.Regular) > 0) { + addErrorf("cannot have both DefaultMailboxes and InitialMailboxes") + } + // DefaultMailboxes is deprecated. for _, mb := range c.DefaultMailboxes { checkMailboxNormf(mb, "default mailbox") } + checkSpecialUseMailbox := func(nameOpt string) { + if nameOpt != "" { + checkMailboxNormf(nameOpt, "special-use initial mailbox") + if strings.EqualFold(nameOpt, "inbox") { + addErrorf("initial mailbox cannot be set to Inbox (Inbox is always created)") + } + } + } + checkSpecialUseMailbox(c.InitialMailboxes.SpecialUse.Archive) + checkSpecialUseMailbox(c.InitialMailboxes.SpecialUse.Draft) + checkSpecialUseMailbox(c.InitialMailboxes.SpecialUse.Junk) + checkSpecialUseMailbox(c.InitialMailboxes.SpecialUse.Sent) + checkSpecialUseMailbox(c.InitialMailboxes.SpecialUse.Trash) + for _, name := range c.InitialMailboxes.Regular { + checkMailboxNormf(name, "regular initial mailbox") + if strings.EqualFold(name, "inbox") { + addErrorf("initial regular mailbox cannot be set to Inbox (Inbox is always created)") + } + } checkTransportSMTP := func(name string, isTLS bool, t *config.TransportSMTP) { var err error diff --git a/store/account.go b/store/account.go index 6f10544..039ece9 100644 --- a/store/account.go +++ b/store/account.go @@ -73,7 +73,15 @@ var ( var subjectpassRand = mox.NewRand() -var InitialMailboxes = []string{"Inbox", "Sent", "Archive", "Trash", "Drafts", "Junk"} +var DefaultInitialMailboxes = config.InitialMailboxes{ + SpecialUse: config.SpecialUseMailboxes{ + Sent: "Sent", + Archive: "Archive", + Trash: "Trash", + Draft: "Drafts", + Junk: "Junk", + }, +} type SCRAM struct { Salt []byte @@ -714,36 +722,79 @@ func initAccount(db *bstore.DB) error { return db.Write(context.TODO(), func(tx *bstore.Tx) error { uidvalidity := InitialUIDValidity() - mailboxes := InitialMailboxes - defaultMailboxes := mox.Conf.Static.DefaultMailboxes - if len(defaultMailboxes) > 0 { - mailboxes = []string{"Inbox"} + if len(mox.Conf.Static.DefaultMailboxes) > 0 { + // Deprecated in favor of InitialMailboxes. + defaultMailboxes := mox.Conf.Static.DefaultMailboxes + mailboxes := []string{"Inbox"} for _, name := range defaultMailboxes { if strings.EqualFold(name, "Inbox") { continue } mailboxes = append(mailboxes, name) } - } - for _, name := range mailboxes { - mb := Mailbox{Name: name, UIDValidity: uidvalidity, UIDNext: 1, HaveCounts: true} - if strings.HasPrefix(name, "Archive") { - mb.Archive = true - } else if strings.HasPrefix(name, "Drafts") { - mb.Draft = true - } else if strings.HasPrefix(name, "Junk") { - mb.Junk = true - } else if strings.HasPrefix(name, "Sent") { - mb.Sent = true - } else if strings.HasPrefix(name, "Trash") { - mb.Trash = true + for _, name := range mailboxes { + mb := Mailbox{Name: name, UIDValidity: uidvalidity, UIDNext: 1, HaveCounts: true} + if strings.HasPrefix(name, "Archive") { + mb.Archive = true + } else if strings.HasPrefix(name, "Drafts") { + mb.Draft = true + } else if strings.HasPrefix(name, "Junk") { + mb.Junk = true + } else if strings.HasPrefix(name, "Sent") { + mb.Sent = true + } else if strings.HasPrefix(name, "Trash") { + mb.Trash = true + } + if err := tx.Insert(&mb); err != nil { + return fmt.Errorf("creating mailbox: %w", err) + } + if err := tx.Insert(&Subscription{name}); err != nil { + return fmt.Errorf("adding subscription: %w", err) + } } - if err := tx.Insert(&mb); err != nil { - return fmt.Errorf("creating mailbox: %w", err) + } else { + mailboxes := mox.Conf.Static.InitialMailboxes + var zerouse config.SpecialUseMailboxes + if mailboxes.SpecialUse == zerouse && len(mailboxes.Regular) == 0 { + mailboxes = DefaultInitialMailboxes } - if err := tx.Insert(&Subscription{name}); err != nil { - return fmt.Errorf("adding subscription: %w", err) + add := func(name string, use SpecialUse) error { + mb := Mailbox{Name: name, UIDValidity: uidvalidity, UIDNext: 1, SpecialUse: use, HaveCounts: true} + if err := tx.Insert(&mb); err != nil { + return fmt.Errorf("creating mailbox: %w", err) + } + if err := tx.Insert(&Subscription{name}); err != nil { + return fmt.Errorf("adding subscription: %w", err) + } + return nil + } + addSpecialOpt := func(nameOpt string, use SpecialUse) error { + if nameOpt == "" { + return nil + } + return add(nameOpt, use) + } + l := []struct { + nameOpt string + use SpecialUse + }{ + {"Inbox", SpecialUse{}}, + {mailboxes.SpecialUse.Archive, SpecialUse{Archive: true}}, + {mailboxes.SpecialUse.Draft, SpecialUse{Draft: true}}, + {mailboxes.SpecialUse.Junk, SpecialUse{Junk: true}}, + {mailboxes.SpecialUse.Sent, SpecialUse{Sent: true}}, + {mailboxes.SpecialUse.Trash, SpecialUse{Trash: true}}, + } + for _, e := range l { + if err := addSpecialOpt(e.nameOpt, e.use); err != nil { + return err + } + } + for _, name := range mailboxes.Regular { + if err := add(name, SpecialUse{}); err != nil { + return err + } } } @@ -934,8 +985,9 @@ func (a *Account) WithRLock(fn func()) { // section. The caller is responsible for adding a header separator to // msg.MsgPrefix if missing from an incoming message. // -// If isSent is true, the message is parsed for its recipients (to/cc/bcc). Their -// domains are added to Recipients for use in dmarc reputation. +// If the destination mailbox has the Sent special-use flag, the message is parsed +// for its recipients (to/cc/bcc). Their domains are added to Recipients for use in +// dmarc reputation. // // If sync is true, the message file and its directory are synced. Should be true // for regular mail delivery, but can be false when importing many messages. @@ -947,7 +999,7 @@ func (a *Account) WithRLock(fn func()) { // Caller must broadcast new message. // // Caller must update mailbox counts. -func (a *Account) DeliverMessage(log *mlog.Log, tx *bstore.Tx, m *Message, msgFile *os.File, consumeFile, isSent, sync, notrain bool) error { +func (a *Account) DeliverMessage(log *mlog.Log, tx *bstore.Tx, m *Message, msgFile *os.File, consumeFile, sync, notrain bool) error { if m.Expunged { return fmt.Errorf("cannot deliver expunged message") } @@ -999,7 +1051,7 @@ func (a *Account) DeliverMessage(log *mlog.Log, tx *bstore.Tx, m *Message, msgFi } // todo: perhaps we should match the recipients based on smtp submission and a matching message-id? we now miss the addresses in bcc's. for webmail, we could insert the recipients directly. - if isSent { + if mb.Sent { // Attempt to parse the message for its To/Cc/Bcc headers, which we insert into Recipient. if part == nil { var p message.Part @@ -1405,7 +1457,7 @@ func (a *Account) DeliverMailbox(log *mlog.Log, mailbox string, m *Message, msgF return fmt.Errorf("updating mailbox for delivery: %w", err) } - if err := a.DeliverMessage(log, tx, m, msgFile, consumeFile, mb.Sent, true, false); err != nil { + if err := a.DeliverMessage(log, tx, m, msgFile, consumeFile, true, false); err != nil { return err } diff --git a/store/account_test.go b/store/account_test.go index cd151a0..38eb8a2 100644 --- a/store/account_test.go +++ b/store/account_test.go @@ -77,7 +77,7 @@ func TestMailbox(t *testing.T) { tcheck(t, err, "sent mailbox") msent.MailboxID = mbsent.ID msent.MailboxOrigID = mbsent.ID - err = acc.DeliverMessage(xlog, tx, &msent, msgFile, false, true, true, false) + err = acc.DeliverMessage(xlog, tx, &msent, msgFile, false, true, false) tcheck(t, err, "deliver message") err = tx.Get(&mbsent) @@ -90,7 +90,7 @@ func TestMailbox(t *testing.T) { tcheck(t, err, "insert rejects mailbox") mreject.MailboxID = mbrejects.ID mreject.MailboxOrigID = mbrejects.ID - err = acc.DeliverMessage(xlog, tx, &mreject, msgFile, false, false, true, false) + err = acc.DeliverMessage(xlog, tx, &mreject, msgFile, false, true, false) tcheck(t, err, "deliver message") err = tx.Get(&mbrejects) diff --git a/webaccount/import.go b/webaccount/import.go index 3ce1547..629778c 100644 --- a/webaccount/import.go +++ b/webaccount/import.go @@ -531,7 +531,7 @@ func importMessages(ctx context.Context, log *mlog.Log, token string, acc *store const consumeFile = true const sync = false const notrain = true - if err := acc.DeliverMessage(log, tx, m, f, consumeFile, mb.Sent, sync, notrain); err != nil { + if err := acc.DeliverMessage(log, tx, m, f, consumeFile, sync, notrain); err != nil { problemf("delivering message %s: %s (continuing)", pos, err) return } diff --git a/webmail/api.go b/webmail/api.go index 98c582a..3b8cc60 100644 --- a/webmail/api.go +++ b/webmail/api.go @@ -748,7 +748,7 @@ func (w Webmail) MessageSubmit(ctx context.Context, m SubmitMessage) { err = tx.Update(&sentmb) xcheckf(ctx, err, "updating sent mailbox for counts") - err = acc.DeliverMessage(log, tx, &sentm, dataFile, true, true, true, false) + err = acc.DeliverMessage(log, tx, &sentm, dataFile, true, true, false) if err != nil { metricSubmission.WithLabelValues("storesenterror").Inc() metricked = true