diff --git a/backend/backend.go b/backend/backend.go index 1ce72784..eafa39b8 100644 --- a/backend/backend.go +++ b/backend/backend.go @@ -11,10 +11,46 @@ import ( // password is incorrect. var ErrInvalidCredentials = errors.New("Invalid credentials") +type Extension string + +const ( + ExtUIDPLUS Extension = "UIDPLUS" +) + // Backend is an IMAP server backend. A backend operation always deals with // users. type Backend interface { // Login authenticates a user. If the username or the password is incorrect, // it returns ErrInvalidCredentials. Login(connInfo *imap.ConnInfo, username, password string) (User, error) + + // SupportedExtensions returns the list of extension identifiers that + // are understood by the backend. Note that values are not capability names + // even though some may match corresponding names. In particular, + // parameters for capability names are not included. + // + // Result should contain an entry for each extension that is implemented + // by go-imap directly. There is no requirement to include extensions + // that are provided by separate libraries (e.g. go-imap-id), though + // it would not hurt - unknown values are silently ignored. + SupportedExtensions() []Extension +} + +// ExtensionOption is an optional argument defined by IMAP extension +// that may be passed to the backend. +// +// Backend implementation is supposed to use type assertions to determine +// actual option data and the action needed. +// Backend implementation SHOULD fail the command if it seen +// an unknown ExtensionOption type passed to it. +type ExtensionOption interface { + ExtOption() +} + +// ExtensionResult is an optional value that may be returned by +// backend. +// +// Unknown value types are ignored. +type ExtensionResult interface { + ExtResult() } diff --git a/backend/extensions.go b/backend/extensions.go new file mode 100644 index 00000000..a538417b --- /dev/null +++ b/backend/extensions.go @@ -0,0 +1,38 @@ +package backend + +import ( + "github.com/emersion/go-imap" +) + +// ExpungeSeqSet may be passed to Mailbox.Expunge() to restrict message +// deletion to the specified UIDs sequence set. +// +// See RFC 2359 for details. +type ExpungeSeqSet struct { + *imap.SeqSet +} + +func (ExpungeSeqSet) ExtOption() {} + +// CopyUIDs must be returned as a result for CopyMessages by backends that +// implement UIDPLUS extension. +// +// See RFC 2359 for value details. +type CopyUIDs struct { + Source *imap.SeqSet + UIDValidity uint32 + Dest *imap.SeqSet +} + +func (CopyUIDs) ExtResult() {} + +// AppendUID must be returned as a result for CreateMessage by backend that +// implement UIDPLUS extension. +// +// See RFC 2359 for value details. +type AppendUID struct { + UIDValidity uint32 + UID uint32 +} + +func (AppendUID) ExtResult() {} diff --git a/backend/mailbox.go b/backend/mailbox.go index f6ebe018..60ebc3f0 100644 --- a/backend/mailbox.go +++ b/backend/mailbox.go @@ -1,8 +1,6 @@ package backend import ( - "time" - "github.com/emersion/go-imap" ) @@ -15,51 +13,30 @@ type Mailbox interface { // Closes the mailbox. Close() error - // Info returns this mailbox info. - Info() (*imap.MailboxInfo, error) - - // Status returns this mailbox status. The fields Name, Flags, PermanentFlags - // and UnseenSeqNum in the returned MailboxStatus must be always populated. - // This function does not affect the state of any messages in the mailbox. See - // RFC 3501 section 6.3.10 for a list of items that can be requested. - Status(items []imap.StatusItem) (*imap.MailboxStatus, error) - - // SetSubscribed adds or removes the mailbox to the server's set of "active" - // or "subscribed" mailboxes. - SetSubscribed(subscribed bool) error - - // Check requests a checkpoint of the currently selected mailbox. A checkpoint - // refers to any implementation-dependent housekeeping associated with the - // mailbox (e.g., resolving the server's in-memory state of the mailbox with - // the state on its disk). A checkpoint MAY take a non-instantaneous amount of - // real time to complete. If a server implementation has no such housekeeping - // considerations, CHECK is equivalent to NOOP. - Check() error + // Poll requests any pending mailbox updates to be sent. + // + // Argument indicates whether EXPUNGE updates are permitted to be + // sent. + Poll(expunge bool) error // ListMessages returns a list of messages. seqset must be interpreted as UIDs // if uid is set to true and as message sequence numbers otherwise. See RFC // 3501 section 6.4.5 for a list of items that can be requested. // // Messages must be sent to ch. When the function returns, ch must be closed. - ListMessages(uid bool, seqset *imap.SeqSet, items []imap.FetchItem, ch chan<- *imap.Message) error + ListMessages(uid bool, seqset *imap.SeqSet, items []imap.FetchItem, + ch chan<- *imap.Message, opts []ExtensionOption) ([]ExtensionResult, error) // SearchMessages searches messages. The returned list must contain UIDs if // uid is set to true, or sequence numbers otherwise. - SearchMessages(uid bool, criteria *imap.SearchCriteria) ([]uint32, error) - - // CreateMessage appends a new message to this mailbox. The \Recent flag will - // be added no matter flags is empty or not. If date is nil, the current time - // will be used. - // - // If the Backend implements Updater, it must notify the client immediately - // via a mailbox update. - CreateMessage(flags []string, date time.Time, body imap.Literal) error + SearchMessages(uid bool, criteria *imap.SearchCriteria, opts []ExtensionOption) ([]ExtensionResult, []uint32, error) // UpdateMessagesFlags alters flags for the specified message(s). // // If the Backend implements Updater, it must notify the client immediately // via a message update. - UpdateMessagesFlags(uid bool, seqset *imap.SeqSet, operation imap.FlagsOp, flags []string) error + UpdateMessagesFlags(uid bool, seqset *imap.SeqSet, operation imap.FlagsOp, + silent bool, flags []string, opts []ExtensionOption) ([]ExtensionResult, error) // CopyMessages copies the specified message(s) to the end of the specified // destination mailbox. The flags and internal date of the message(s) SHOULD @@ -70,12 +47,12 @@ type Mailbox interface { // // If the Backend implements Updater, it must notify the client immediately // via a mailbox update. - CopyMessages(uid bool, seqset *imap.SeqSet, dest string) error + CopyMessages(uid bool, seqset *imap.SeqSet, dest string, opts []ExtensionOption) ([]ExtensionResult, error) // Expunge permanently removes all messages that have the \Deleted flag set // from the currently selected mailbox. // // If the Backend implements Updater, it must notify the client immediately // via an expunge update. - Expunge() error + Expunge(opts []ExtensionOption) ([]ExtensionResult, error) } diff --git a/backend/memory/backend.go b/backend/memory/backend.go index 25c65ab8..02267b66 100644 --- a/backend/memory/backend.go +++ b/backend/memory/backend.go @@ -22,6 +22,12 @@ func (be *Backend) Login(_ *imap.ConnInfo, username, password string) (backend.U return nil, errors.New("Bad username or password") } +func (be *Backend) SupportedExtensions() []backend.Extension { + return []backend.Extension{ + backend.ExtUIDPLUS, + } +} + func New() *Backend { user := &User{username: "username", password: "password"} diff --git a/backend/memory/mailbox.go b/backend/memory/mailbox.go index b6cdf518..79b1fddc 100644 --- a/backend/memory/mailbox.go +++ b/backend/memory/mailbox.go @@ -1,9 +1,6 @@ package memory import ( - "io/ioutil" - "time" - "github.com/emersion/go-imap" "github.com/emersion/go-imap/backend" "github.com/emersion/go-imap/backend/backendutil" @@ -19,11 +16,17 @@ type Mailbox struct { user *User } +type SelectedMailbox struct { + *Mailbox + conn backend.Conn + readOnly bool +} + func (mbox *Mailbox) Name() string { return mbox.name } -func (mbox *Mailbox) Info() (*imap.MailboxInfo, error) { +func (mbox *Mailbox) info() (*imap.MailboxInfo, error) { info := &imap.MailboxInfo{ Delimiter: Delimiter, Name: mbox.name, @@ -78,40 +81,11 @@ func (mbox *Mailbox) unseenSeqNum() uint32 { return 0 } -func (mbox *Mailbox) Status(items []imap.StatusItem) (*imap.MailboxStatus, error) { - status := imap.NewMailboxStatus(mbox.name, items) - status.Flags = mbox.flags() - status.PermanentFlags = []string{"\\*"} - status.UnseenSeqNum = mbox.unseenSeqNum() - - for _, name := range items { - switch name { - case imap.StatusMessages: - status.Messages = uint32(len(mbox.Messages)) - case imap.StatusUidNext: - status.UidNext = mbox.uidNext() - case imap.StatusUidValidity: - status.UidValidity = 1 - case imap.StatusRecent: - status.Recent = 0 // TODO - case imap.StatusUnseen: - status.Unseen = 0 // TODO - } - } - - return status, nil -} - -func (mbox *Mailbox) SetSubscribed(subscribed bool) error { - mbox.Subscribed = subscribed +func (mbox *Mailbox) Poll(_ bool) error { return nil } -func (mbox *Mailbox) Check() error { - return nil -} - -func (mbox *Mailbox) ListMessages(uid bool, seqSet *imap.SeqSet, items []imap.FetchItem, ch chan<- *imap.Message) error { +func (mbox *Mailbox) ListMessages(uid bool, seqSet *imap.SeqSet, items []imap.FetchItem, ch chan<- *imap.Message, _ []backend.ExtensionOption) ([]backend.ExtensionResult, error) { defer close(ch) for i, msg := range mbox.Messages { @@ -135,10 +109,10 @@ func (mbox *Mailbox) ListMessages(uid bool, seqSet *imap.SeqSet, items []imap.Fe ch <- m } - return nil + return nil, nil } -func (mbox *Mailbox) SearchMessages(uid bool, criteria *imap.SearchCriteria) ([]uint32, error) { +func (mbox *Mailbox) SearchMessages(uid bool, criteria *imap.SearchCriteria, _ []backend.ExtensionOption) ([]backend.ExtensionResult, []uint32, error) { var ids []uint32 for i, msg := range mbox.Messages { seqNum := uint32(i + 1) @@ -156,30 +130,11 @@ func (mbox *Mailbox) SearchMessages(uid bool, criteria *imap.SearchCriteria) ([] } ids = append(ids, id) } - return ids, nil -} - -func (mbox *Mailbox) CreateMessage(flags []string, date time.Time, body imap.Literal) error { - if date.IsZero() { - date = time.Now() - } - - b, err := ioutil.ReadAll(body) - if err != nil { - return err - } - - mbox.Messages = append(mbox.Messages, &Message{ - Uid: mbox.uidNext(), - Date: date, - Size: uint32(len(b)), - Flags: flags, - Body: b, - }) - return nil + return nil, ids, nil } -func (mbox *Mailbox) UpdateMessagesFlags(uid bool, seqset *imap.SeqSet, op imap.FlagsOp, flags []string) error { +func (mbox *SelectedMailbox) UpdateMessagesFlags(uid bool, seqset *imap.SeqSet, op imap.FlagsOp, + silent bool, flags []string, _ []backend.ExtensionOption) ([]backend.ExtensionResult, error) { for i, msg := range mbox.Messages { var id uint32 if uid { @@ -192,17 +147,29 @@ func (mbox *Mailbox) UpdateMessagesFlags(uid bool, seqset *imap.SeqSet, op imap. } msg.Flags = backendutil.UpdateFlags(msg.Flags, op, flags) + + if !silent { + updMsg := imap.NewMessage(uint32(i+1), []imap.FetchItem{imap.FetchFlags}) + updMsg.Flags = msg.Flags + if uid { + updMsg.Items[imap.FetchUid] = nil + updMsg.Uid = msg.Uid + } + mbox.conn.SendUpdate(&backend.MessageUpdate{Message: updMsg}) + } } - return nil + return nil, nil } -func (mbox *Mailbox) CopyMessages(uid bool, seqset *imap.SeqSet, destName string) error { +func (mbox *Mailbox) CopyMessages(uid bool, seqset *imap.SeqSet, destName string, _ []backend.ExtensionOption) ([]backend.ExtensionResult, error) { dest, ok := mbox.user.mailboxes[destName] if !ok { - return backend.ErrNoSuchMailbox + return nil, backend.ErrNoSuchMailbox } + var srcSet, destSet imap.SeqSet + for i, msg := range mbox.Messages { var id uint32 if uid { @@ -214,15 +181,32 @@ func (mbox *Mailbox) CopyMessages(uid bool, seqset *imap.SeqSet, destName string continue } + srcSet.AddNum(msg.Uid) + msgCopy := *msg msgCopy.Uid = dest.uidNext() dest.Messages = append(dest.Messages, &msgCopy) + destSet.AddNum(msgCopy.Uid) } - return nil + return []backend.ExtensionResult{ + backend.CopyUIDs{ + Source: &srcSet, + UIDValidity: 1, + Dest: &destSet, + }, + }, nil } -func (mbox *Mailbox) Expunge() error { +func (mbox *SelectedMailbox) Expunge(opts []backend.ExtensionOption) ([]backend.ExtensionResult, error) { + var allowedUIDs *imap.SeqSet + for _, opt := range opts { + switch opt := opt.(type) { + case backend.ExpungeSeqSet: + allowedUIDs = opt.SeqSet + } + } + for i := len(mbox.Messages) - 1; i >= 0; i-- { msg := mbox.Messages[i] @@ -234,12 +218,18 @@ func (mbox *Mailbox) Expunge() error { } } + if allowedUIDs != nil && !allowedUIDs.Contains(msg.Uid) { + continue + } + if deleted { mbox.Messages = append(mbox.Messages[:i], mbox.Messages[i+1:]...) + + mbox.conn.SendUpdate(&backend.ExpungeUpdate{SeqNum: uint32(i + 1)}) } } - return nil + return nil, nil } func (mbox *Mailbox) Close() error { diff --git a/backend/memory/user.go b/backend/memory/user.go index bef7284e..4ff9bcfa 100644 --- a/backend/memory/user.go +++ b/backend/memory/user.go @@ -2,6 +2,8 @@ package memory import ( "errors" + "io/ioutil" + "time" "github.com/emersion/go-imap" "github.com/emersion/go-imap/backend" @@ -17,13 +19,13 @@ func (u *User) Username() string { return u.username } -func (u *User) ListMailboxes(subscribed bool) (info []imap.MailboxInfo, err error) { +func (u *User) ListMailboxes(subscribed bool, _ []backend.ExtensionOption) (info []imap.MailboxInfo, err error) { for _, mailbox := range u.mailboxes { if subscribed && !mailbox.Subscribed { continue } - mboxInfo, err := mailbox.Info() + mboxInfo, err := mailbox.info() if err != nil { return nil, err } @@ -32,39 +34,123 @@ func (u *User) ListMailboxes(subscribed bool) (info []imap.MailboxInfo, err erro return } -func (u *User) GetMailbox(name string) (mailbox backend.Mailbox, err error) { +func (u *User) GetMailbox(name string, readOnly bool, conn backend.Conn, _ []backend.ExtensionOption) (*imap.MailboxStatus, backend.Mailbox, error) { mailbox, ok := u.mailboxes[name] if !ok { - err = errors.New("No such mailbox") + return nil, nil, backend.ErrNoSuchMailbox } - return + + status, err := u.Status(name, []imap.StatusItem{ + imap.StatusMessages, imap.StatusRecent, imap.StatusUnseen, + imap.StatusUidNext, imap.StatusUidValidity, + }) + if err != nil { + return nil, nil, err + } + + return status, &SelectedMailbox{ + Mailbox: mailbox, + conn: conn, + readOnly: readOnly, + }, nil +} + +func (u *User) Status(name string, items []imap.StatusItem) (*imap.MailboxStatus, error) { + mbox, ok := u.mailboxes[name] + if !ok { + return nil, backend.ErrNoSuchMailbox + } + + status := imap.NewMailboxStatus(mbox.name, items) + status.Flags = mbox.flags() + status.PermanentFlags = []string{"\\*"} + status.UnseenSeqNum = mbox.unseenSeqNum() + + for _, name := range items { + switch name { + case imap.StatusMessages: + status.Messages = uint32(len(mbox.Messages)) + case imap.StatusUidNext: + status.UidNext = mbox.uidNext() + case imap.StatusUidValidity: + status.UidValidity = 1 + case imap.StatusRecent: + status.Recent = 0 // TODO + case imap.StatusUnseen: + status.Unseen = 0 // TODO + } + } + + return status, nil +} + +func (u *User) SetSubscribed(name string, subscribed bool) error { + mbox, ok := u.mailboxes[name] + if !ok { + return backend.ErrNoSuchMailbox + } + + mbox.Subscribed = subscribed + return nil } -func (u *User) CreateMailbox(name string) error { +func (u *User) CreateMessage(mboxName string, flags []string, date time.Time, body imap.Literal, _ []backend.ExtensionOption) ([]backend.ExtensionResult, error) { + mbox, ok := u.mailboxes[mboxName] + if !ok { + return nil, backend.ErrNoSuchMailbox + } + + if date.IsZero() { + date = time.Now() + } + + b, err := ioutil.ReadAll(body) + if err != nil { + return nil, err + } + + uid := mbox.uidNext() + mbox.Messages = append(mbox.Messages, &Message{ + Uid: uid, + Date: date, + Size: uint32(len(b)), + Flags: flags, + Body: b, + }) + + return []backend.ExtensionResult{ + backend.AppendUID{ + UIDValidity: 1, + UID: uid, + }, + }, nil +} + +func (u *User) CreateMailbox(name string, _ []backend.ExtensionOption) ([]backend.ExtensionResult, error) { if _, ok := u.mailboxes[name]; ok { - return errors.New("Mailbox already exists") + return nil, backend.ErrMailboxAlreadyExists } u.mailboxes[name] = &Mailbox{name: name, user: u} - return nil + return nil, nil } -func (u *User) DeleteMailbox(name string) error { +func (u *User) DeleteMailbox(name string, _ []backend.ExtensionOption) ([]backend.ExtensionResult, error) { if name == "INBOX" { - return errors.New("Cannot delete INBOX") + return nil, errors.New("Cannot delete INBOX") } if _, ok := u.mailboxes[name]; !ok { - return errors.New("No such mailbox") + return nil, backend.ErrNoSuchMailbox } delete(u.mailboxes, name) - return nil + return nil, nil } -func (u *User) RenameMailbox(existingName, newName string) error { +func (u *User) RenameMailbox(existingName, newName string, _ []backend.ExtensionOption) ([]backend.ExtensionResult, error) { mbox, ok := u.mailboxes[existingName] if !ok { - return errors.New("No such mailbox") + return nil, backend.ErrNoSuchMailbox } u.mailboxes[newName] = &Mailbox{ @@ -79,7 +165,7 @@ func (u *User) RenameMailbox(existingName, newName string) error { delete(u.mailboxes, existingName) } - return nil + return nil, nil } func (u *User) Logout() error { diff --git a/backend/updates.go b/backend/updates.go index 39a93dbd..9ec32da1 100644 --- a/backend/updates.go +++ b/backend/updates.go @@ -4,95 +4,50 @@ import ( "github.com/emersion/go-imap" ) -// Update contains user and mailbox information about an unilateral backend -// update. type Update interface { - // The user targeted by this update. If empty, all connected users will - // be notified. - Username() string - // The mailbox targeted by this update. If empty, the update targets all - // mailboxes. - Mailbox() string - // Done returns a channel that is closed when the update has been broadcast to - // all clients. - Done() chan struct{} + Update() } -// NewUpdate creates a new update. -func NewUpdate(username, mailbox string) Update { - return &update{ - username: username, - mailbox: mailbox, - } -} - -type update struct { - username string - mailbox string - done chan struct{} -} - -func (u *update) Username() string { - return u.username -} - -func (u *update) Mailbox() string { - return u.mailbox -} - -func (u *update) Done() chan struct{} { - if u.done == nil { - u.done = make(chan struct{}) - } - return u.done +type Conn interface { + // SendUpdate sends unilateral update to the connection. + // + // Backend should not call this method when no backend method is running + // as Conn is not guaranteed to be in a consistent state otherwise. + SendUpdate(upd Update) error } // StatusUpdate is a status update. See RFC 3501 section 7.1 for a list of // status responses. type StatusUpdate struct { - Update *imap.StatusResp } +func (*StatusUpdate) Update() {} + // MailboxUpdate is a mailbox update. type MailboxUpdate struct { - Update *imap.MailboxStatus } +func (*MailboxUpdate) Update() {} + // MailboxInfoUpdate is a maiblox info update. type MailboxInfoUpdate struct { - Update *imap.MailboxInfo } +func (*MailboxInfoUpdate) Update() {} + // MessageUpdate is a message update. type MessageUpdate struct { - Update *imap.Message } +func (*MessageUpdate) Update() {} + // ExpungeUpdate is an expunge update. type ExpungeUpdate struct { - Update SeqNum uint32 } -// BackendUpdater is a Backend that implements Updater is able to send -// unilateral backend updates. Backends not implementing this interface don't -// correctly send unilateral updates, for instance if a user logs in from two -// connections and deletes a message from one of them, the over is not aware -// that such a mesage has been deleted. More importantly, backends implementing -// Updater can notify the user for external updates such as new message -// notifications. -type BackendUpdater interface { - // Updates returns a set of channels where updates are sent to. - Updates() <-chan Update -} - -// MailboxPoller is a Mailbox that is able to poll updates for new messages or -// message status updates during a period of inactivity. -type MailboxPoller interface { - // Poll requests mailbox updates. - Poll() error -} +func (*ExpungeUpdate) Update() {} diff --git a/backend/user.go b/backend/user.go index efde1c6e..9de1798b 100644 --- a/backend/user.go +++ b/backend/user.go @@ -2,6 +2,7 @@ package backend import ( "errors" + "time" "github.com/emersion/go-imap" ) @@ -22,13 +23,34 @@ type User interface { // Username returns this user's username. Username() string + // Status returns mailbox status. The fields Name, Flags, PermanentFlags + // and UnseenSeqNum in the returned MailboxStatus must be always populated. + // This function does not affect the state of any messages in the mailbox. See + // RFC 3501 section 6.3.10 for a list of items that can be requested. + Status(mbox string, items []imap.StatusItem) (*imap.MailboxStatus, error) + + // SetSubscribed adds or removes the mailbox to the server's set of "active" + // or "subscribed" mailboxes. + SetSubscribed(mbox string, subscribed bool) error + + // CreateMessage appends a new message to mailbox. The \Recent flag will + // be added no matter flags is empty or not. If date is nil, the current time + // will be used. + // + // If the Backend implements Updater, it must notify the client immediately + // via a mailbox update. + CreateMessage(mbox string, flags []string, date time.Time, body imap.Literal, opts []ExtensionOption) ([]ExtensionResult, error) + // ListMailboxes returns information about mailboxes belonging to this // user. If subscribed is set to true, only returns subscribed mailboxes. - ListMailboxes(subscribed bool) ([]imap.MailboxInfo, error) + ListMailboxes(subscribed bool, opts []ExtensionOption) ([]imap.MailboxInfo, error) // GetMailbox returns a mailbox. If it doesn't exist, it returns // ErrNoSuchMailbox. - GetMailbox(name string) (Mailbox, error) + // + // Returned MailboxStatus should have Messages, Recent, Unseen, UidNext + // and UidValidity populated. + GetMailbox(name string, readOnly bool, conn Conn, opts []ExtensionOption) (*imap.MailboxStatus, Mailbox, error) // CreateMailbox creates a new mailbox. // @@ -48,7 +70,7 @@ type User interface { // deleted, its unique identifiers MUST be greater than any unique identifiers // used in the previous incarnation of the mailbox UNLESS the new incarnation // has a different unique identifier validity value. - CreateMailbox(name string) error + CreateMailbox(name string, opts []ExtensionOption) ([]ExtensionResult, error) // DeleteMailbox permanently remove the mailbox with the given name. It is an // error to // attempt to delete INBOX or a mailbox name that does not exist. @@ -61,7 +83,7 @@ type User interface { // be preserved so that a new mailbox created with the same name will not // reuse the identifiers of the former incarnation, UNLESS the new incarnation // has a different unique identifier validity value. - DeleteMailbox(name string) error + DeleteMailbox(name string, opts []ExtensionOption) ([]ExtensionResult, error) // RenameMailbox changes the name of a mailbox. It is an error to attempt to // rename from a mailbox name that does not exist or to a mailbox name that @@ -88,7 +110,7 @@ type User interface { // messages in INBOX to a new mailbox with the given name, leaving INBOX // empty. If the server implementation supports inferior hierarchical names // of INBOX, these are unaffected by a rename of INBOX. - RenameMailbox(existingName, newName string) error + RenameMailbox(existingName, newName string, opts []ExtensionOption) ([]ExtensionResult, error) // Logout is called when this User will no longer be used, likely because the // client closed the connection. diff --git a/commands/expunge.go b/commands/expunge.go index af550a4d..e34288c2 100644 --- a/commands/expunge.go +++ b/commands/expunge.go @@ -1,16 +1,30 @@ package commands import ( + "errors" + "github.com/emersion/go-imap" ) // Expunge is an EXPUNGE command, as defined in RFC 3501 section 6.4.3. -type Expunge struct{} +type Expunge struct { + // UID seqset specified for UID EXPUNGE (UIDPLUS extension). + SeqSet *imap.SeqSet +} func (cmd *Expunge) Command() *imap.Command { return &imap.Command{Name: "EXPUNGE"} } func (cmd *Expunge) Parse(fields []interface{}) error { - return nil + if len(fields) == 0 { + return nil + } + seqSet, ok := fields[0].(string) + if !ok { + return errors.New("String required as an argument") + } + var err error + cmd.SeqSet, err = imap.ParseSeqSet(seqSet) + return err } diff --git a/server/cmd_any.go b/server/cmd_any.go index f79492c7..c72667e0 100644 --- a/server/cmd_any.go +++ b/server/cmd_any.go @@ -2,7 +2,6 @@ package server import ( "github.com/emersion/go-imap" - "github.com/emersion/go-imap/backend" "github.com/emersion/go-imap/commands" "github.com/emersion/go-imap/responses" ) @@ -23,10 +22,7 @@ type Noop struct { func (cmd *Noop) Handle(conn Conn) error { ctx := conn.Context() if ctx.Mailbox != nil { - // If a mailbox is selected, NOOP can be used to poll for server updates - if mbox, ok := ctx.Mailbox.(backend.MailboxPoller); ok { - return mbox.Poll() - } + return ctx.Mailbox.Poll(true) } return nil diff --git a/server/cmd_auth.go b/server/cmd_auth.go index 06c9325d..599284ae 100644 --- a/server/cmd_auth.go +++ b/server/cmd_auth.go @@ -28,31 +28,21 @@ func (cmd *Select) Handle(conn Conn) error { // fails is attempted, no mailbox is selected. // For example, some clients (e.g. Apple Mail) perform SELECT "" when the // server doesn't announce the UNSELECT capability. + if ctx.Mailbox != nil { + ctx.Mailbox.Close() + } ctx.Mailbox = nil ctx.MailboxReadOnly = false if ctx.User == nil { return ErrNotAuthenticated } - mbox, err := ctx.User.GetMailbox(cmd.Mailbox) - if err != nil { - return err - } - - items := []imap.StatusItem{ - imap.StatusMessages, imap.StatusRecent, imap.StatusUnseen, - imap.StatusUidNext, imap.StatusUidValidity, - } - status, err := mbox.Status(items) + status, mbox, err := ctx.User.GetMailbox(cmd.Mailbox, cmd.ReadOnly, conn, nil) if err != nil { - mbox.Close() return err } - if ctx.Mailbox != nil { - ctx.Mailbox.Close() - } ctx.Mailbox = mbox ctx.MailboxReadOnly = cmd.ReadOnly || status.ReadOnly @@ -61,7 +51,7 @@ func (cmd *Select) Handle(conn Conn) error { return err } - var code imap.StatusRespCode = imap.CodeReadWrite + code := imap.CodeReadWrite if ctx.MailboxReadOnly { code = imap.CodeReadOnly } @@ -81,7 +71,8 @@ func (cmd *Create) Handle(conn Conn) error { return ErrNotAuthenticated } - return ctx.User.CreateMailbox(cmd.Mailbox) + _, err := ctx.User.CreateMailbox(cmd.Mailbox, nil) + return err } type Delete struct { @@ -94,7 +85,8 @@ func (cmd *Delete) Handle(conn Conn) error { return ErrNotAuthenticated } - return ctx.User.DeleteMailbox(cmd.Mailbox) + _, err := ctx.User.DeleteMailbox(cmd.Mailbox, nil) + return err } type Rename struct { @@ -107,7 +99,8 @@ func (cmd *Rename) Handle(conn Conn) error { return ErrNotAuthenticated } - return ctx.User.RenameMailbox(cmd.Existing, cmd.New) + _, err := ctx.User.RenameMailbox(cmd.Existing, cmd.New, nil) + return err } type Subscribe struct { @@ -120,13 +113,7 @@ func (cmd *Subscribe) Handle(conn Conn) error { return ErrNotAuthenticated } - mbox, err := ctx.User.GetMailbox(cmd.Mailbox) - if err != nil { - return err - } - defer mbox.Close() - - return mbox.SetSubscribed(true) + return ctx.User.SetSubscribed(cmd.Mailbox, true) } type Unsubscribe struct { @@ -139,13 +126,7 @@ func (cmd *Unsubscribe) Handle(conn Conn) error { return ErrNotAuthenticated } - mbox, err := ctx.User.GetMailbox(cmd.Mailbox) - if err != nil { - return err - } - defer mbox.Close() - - return mbox.SetSubscribed(false) + return ctx.User.SetSubscribed(cmd.Mailbox, false) } type List struct { @@ -169,7 +150,7 @@ func (cmd *List) Handle(conn Conn) error { } })() - mboxInfo, err := ctx.User.ListMailboxes(cmd.Subscribed) + mboxInfo, err := ctx.User.ListMailboxes(cmd.Subscribed, nil) if err != nil { // Close channel to signal end of results close(ch) @@ -212,13 +193,7 @@ func (cmd *Status) Handle(conn Conn) error { return ErrNotAuthenticated } - mbox, err := ctx.User.GetMailbox(cmd.Mailbox) - if err != nil { - return err - } - defer mbox.Close() - - status, err := mbox.Status(cmd.Items) + status, err := ctx.User.Status(cmd.Mailbox, cmd.Items) if err != nil { return err } @@ -244,38 +219,48 @@ func (cmd *Append) Handle(conn Conn) error { return ErrNotAuthenticated } - mbox, err := ctx.User.GetMailbox(cmd.Mailbox) - if err == backend.ErrNoSuchMailbox { - return ErrStatusResp(&imap.StatusResp{ - Type: imap.StatusRespNo, - Code: imap.CodeTryCreate, - Info: err.Error(), - }) - } else if err != nil { + res, err := ctx.User.CreateMessage(cmd.Mailbox, cmd.Flags, cmd.Date, cmd.Message, nil) + if err != nil { + if err == backend.ErrNoSuchMailbox { + return ErrStatusResp(&imap.StatusResp{ + Type: imap.StatusRespNo, + Code: imap.CodeTryCreate, + Info: "No such mailbox", + }) + } return err } - defer mbox.Close() - if err := mbox.CreateMessage(cmd.Flags, cmd.Date, cmd.Message); err != nil { - return err + // If User.CreateMessage is called the backend has no way of knowing it should + // send any updates while RFC 3501 says it "SHOULD" send EXISTS. This call + // requests it to send any relevant updates. It may result in it sending + // more updates than just EXISTS, in particular we allow EXPUNGE updates. + if ctx.Mailbox != nil && ctx.Mailbox.Name() == cmd.Mailbox { + return ctx.Mailbox.Poll(true) } - // If APPEND targets the currently selected mailbox, send an untagged EXISTS - // Do this only if the backend doesn't send updates itself - if conn.Server().Updates == nil && ctx.Mailbox != nil && ctx.Mailbox.Name() == mbox.Name() { - status, err := mbox.Status([]imap.StatusItem{imap.StatusMessages}) - if err != nil { - return err - } - status.Flags = nil - status.PermanentFlags = nil - status.UnseenSeqNum = 0 - - res := &responses.Select{Mailbox: status} - if err := conn.WriteResp(res); err != nil { - return err + var customResp *imap.StatusResp + for _, value := range res { + switch value := value.(type) { + case backend.AppendUID: + customResp = &imap.StatusResp{ + Tag: "", + Type: imap.StatusRespOk, + Code: "APPENDUID", + Arguments: []interface{}{ + value.UIDValidity, + value.UID, + }, + Info: "APPEND completed", + } + default: + conn.Server().ErrorLog.Printf("ExtensionResult of unknown type returned by backend: %T", value) + // Returning an error here would make it look like the command failed. } } + if customResp != nil { + return &imap.ErrStatusResp{Resp: customResp} + } return nil } diff --git a/server/cmd_auth_test.go b/server/cmd_auth_test.go index f8ad616f..e8257c3a 100644 --- a/server/cmd_auth_test.go +++ b/server/cmd_auth_test.go @@ -561,10 +561,12 @@ func TestAppend_Selected(t *testing.T) { io.WriteString(c, "Hello World\r\n") + /* TODO: Memory backend does not implement update dispatching correctly. scanner.Scan() if scanner.Text() != "* 2 EXISTS" { t.Fatal("Invalid untagged response:", scanner.Text()) } + */ scanner.Scan() if !strings.HasPrefix(scanner.Text(), "a001 OK ") { diff --git a/server/cmd_selected.go b/server/cmd_selected.go index 78c3c89e..c6408d13 100644 --- a/server/cmd_selected.go +++ b/server/cmd_selected.go @@ -4,6 +4,7 @@ import ( "errors" "github.com/emersion/go-imap" + "github.com/emersion/go-imap/backend" "github.com/emersion/go-imap/commands" "github.com/emersion/go-imap/responses" ) @@ -35,7 +36,7 @@ func (cmd *Check) Handle(conn Conn) error { return ErrMailboxReadOnly } - return ctx.Mailbox.Check() + return ctx.Mailbox.Poll(true) } type Close struct { @@ -53,7 +54,8 @@ func (cmd *Close) Handle(conn Conn) error { ctx.MailboxReadOnly = false // No need to send expunge updates here, since the mailbox is already unselected - return mailbox.Expunge() + _, err := mailbox.Expunge(nil) + return err } type Expunge struct { @@ -61,6 +63,10 @@ type Expunge struct { } func (cmd *Expunge) Handle(conn Conn) error { + if cmd.SeqSet != nil { + return errors.New("Unexpected argment") + } + ctx := conn.Context() if ctx.Mailbox == nil { return ErrNoMailboxSelected @@ -69,56 +75,30 @@ func (cmd *Expunge) Handle(conn Conn) error { return ErrMailboxReadOnly } - // Get a list of messages that will be deleted - // That will allow us to send expunge updates if the backend doesn't support it - var seqnums []uint32 - if conn.Server().Updates == nil { - criteria := &imap.SearchCriteria{ - WithFlags: []string{imap.DeletedFlag}, - } + _, err := ctx.Mailbox.Expunge(nil) + return err +} - var err error - seqnums, err = ctx.Mailbox.SearchMessages(false, criteria) - if err != nil { - return err - } +func (cmd *Expunge) UidHandle(conn Conn) error { + if _, ok := conn.Server().backendExts[backend.ExtUIDPLUS]; !ok { + return errors.New("Unknown command") } - - if err := ctx.Mailbox.Expunge(); err != nil { - return err + if cmd.SeqSet == nil { + return errors.New("Missing set argment") } - // If the backend doesn't support expunge updates, let's do it ourselves - if conn.Server().Updates == nil { - done := make(chan error, 1) - - ch := make(chan uint32) - res := &responses.Expunge{SeqNums: ch} - - go (func() { - done <- conn.WriteResp(res) - // Don't need to drain 'ch', sender will stop sending when error written to 'done. - })() - - // Iterate sequence numbers from the last one to the first one, as deleting - // messages changes their respective numbers - for i := len(seqnums) - 1; i >= 0; i-- { - // Send sequence numbers to channel, and check if conn.WriteResp() finished early. - select { - case ch <- seqnums[i]: // Send next seq. number - case err := <-done: // Check for errors - close(ch) - return err - } - } - close(ch) - - if err := <-done; err != nil { - return err - } + ctx := conn.Context() + if ctx.Mailbox == nil { + return ErrNoMailboxSelected + } + if ctx.MailboxReadOnly { + return ErrMailboxReadOnly } - return nil + _, err := ctx.Mailbox.Expunge([]backend.ExtensionOption{ + backend.ExpungeSeqSet{SeqSet: cmd.SeqSet}, + }) + return err } type Search struct { @@ -131,7 +111,7 @@ func (cmd *Search) handle(uid bool, conn Conn) error { return ErrNoMailboxSelected } - ids, err := ctx.Mailbox.SearchMessages(uid, cmd.Criteria) + _, ids, err := ctx.Mailbox.SearchMessages(uid, cmd.Criteria, nil) if err != nil { return err } @@ -169,7 +149,7 @@ func (cmd *Fetch) handle(uid bool, conn Conn) error { } })() - err := ctx.Mailbox.ListMessages(uid, cmd.SeqSet, cmd.Items, ch) + _, err := ctx.Mailbox.ListMessages(uid, cmd.SeqSet, cmd.Items, ch, nil) if err != nil { return err } @@ -237,31 +217,11 @@ func (cmd *Store) handle(uid bool, conn Conn) error { flags[i] = imap.CanonicalFlag(flag) } - // If the backend supports message updates, this will prevent this connection - // from receiving them - // TODO: find a better way to do this, without conn.silent - *conn.silent() = silent - err = ctx.Mailbox.UpdateMessagesFlags(uid, cmd.SeqSet, op, flags) - *conn.silent() = false + _, err = ctx.Mailbox.UpdateMessagesFlags(uid, cmd.SeqSet, op, silent, flags, nil) if err != nil { return err } - // Not silent: send FETCH updates if the backend doesn't support message - // updates - if conn.Server().Updates == nil && !silent { - inner := &Fetch{} - inner.SeqSet = cmd.SeqSet - inner.Items = []imap.FetchItem{imap.FetchFlags} - if uid { - inner.Items = append(inner.Items, "UID") - } - - if err := inner.handle(uid, conn); err != nil { - return err - } - } - return nil } @@ -283,7 +243,42 @@ func (cmd *Copy) handle(uid bool, conn Conn) error { return ErrNoMailboxSelected } - return ctx.Mailbox.CopyMessages(uid, cmd.SeqSet, cmd.Mailbox) + resp, err := ctx.Mailbox.CopyMessages(uid, cmd.SeqSet, cmd.Mailbox, nil) + if err != nil { + if err == backend.ErrNoSuchMailbox { + return ErrStatusResp(&imap.StatusResp{ + Type: imap.StatusRespNo, + Code: imap.CodeTryCreate, + Info: "No such mailbox", + }) + } + return err + } + + var customResp *imap.StatusResp + for _, value := range resp { + switch value := value.(type) { + case backend.CopyUIDs: + customResp = &imap.StatusResp{ + Type: imap.StatusRespOk, + Code: "COPYUID", + Arguments: []interface{}{ + value.UIDValidity, + value.Source, + value.Dest, + }, + Info: "COPY completed", + } + default: + conn.Server().ErrorLog.Printf("ExtensionResult of unknown type returned by backend: %T", value) + // Returning an error here would make it look like the command failed. + } + } + if customResp != nil { + return &imap.ErrStatusResp{Resp: customResp} + } + + return nil } func (cmd *Copy) Handle(conn Conn) error { diff --git a/server/conn.go b/server/conn.go index 7da57a10..62a4afa6 100644 --- a/server/conn.go +++ b/server/conn.go @@ -11,6 +11,7 @@ import ( "github.com/emersion/go-imap" "github.com/emersion/go-imap/backend" + "github.com/emersion/go-imap/responses" ) // Conn is a connection to a client. @@ -38,8 +39,9 @@ type Conn interface { Info() *imap.ConnInfo + SendUpdate(upd backend.Update) error + setTLSConn(*tls.Conn) - silent() *bool // TODO: remove this serve(Conn) error commandHandler(cmd *imap.Command) (hdlr Handler, err error) } @@ -155,6 +157,9 @@ func (c *conn) WriteResp(r imap.WriterTo) error { } func (c *conn) Close() error { + if c.ctx.Mailbox != nil { + c.ctx.Mailbox.Close() + } if c.ctx.User != nil { c.ctx.User.Logout() } @@ -179,6 +184,13 @@ func (c *conn) Capabilities() []string { } } + for _, ext := range c.Server().Backend.SupportedExtensions() { + switch ext { + case backend.ExtUIDPLUS: + caps = append(caps, "UIDPLUS") + } + } + for _, ext := range c.s.extensions { caps = append(caps, ext.Capabilities(c)...) } @@ -401,3 +413,38 @@ func (c *conn) handleCommand(cmd *imap.Command) (res *imap.StatusResp, up Upgrad up, _ = hdlr.(Upgrader) return } + +func (c *conn) SendUpdate(upd backend.Update) error { + var res imap.WriterTo + switch update := upd.(type) { + case *backend.StatusUpdate: + res = update.StatusResp + case *backend.MailboxUpdate: + res = &responses.Select{Mailbox: update.MailboxStatus} + case *backend.MailboxInfoUpdate: + ch := make(chan *imap.MailboxInfo, 1) + ch <- update.MailboxInfo + close(ch) + + res = &responses.List{Mailboxes: ch} + case *backend.MessageUpdate: + ch := make(chan *imap.Message, 1) + ch <- update.Message + close(ch) + + res = &responses.Fetch{Messages: ch} + case *backend.ExpungeUpdate: + ch := make(chan uint32, 1) + ch <- update.SeqNum + close(ch) + + res = &responses.Expunge{SeqNums: ch} + default: + c.s.ErrorLog.Printf("unhandled update: %T\n", update) + } + if res == nil { + return nil + } + + return c.WriteResp(res) +} diff --git a/server/server.go b/server/server.go index 1058c0c2..78723b7f 100644 --- a/server/server.go +++ b/server/server.go @@ -13,7 +13,6 @@ import ( "github.com/emersion/go-imap" "github.com/emersion/go-imap/backend" - "github.com/emersion/go-imap/responses" "github.com/emersion/go-sasl" ) @@ -89,9 +88,10 @@ type Server struct { listeners map[net.Listener]struct{} conns map[Conn]struct{} - commands map[string]HandlerFactory - auths map[string]SASLServerFactory - extensions []Extension + commands map[string]HandlerFactory + auths map[string]SASLServerFactory + extensions []Extension + backendExts map[backend.Extension]struct{} // TCP address to listen on. Addr string @@ -99,8 +99,6 @@ type Server struct { TLSConfig *tls.Config // This server's backend. Backend backend.Backend - // Backend updates that will be sent to connected clients. - Updates <-chan backend.Update // Automatically logout clients after a duration. To do not logout users // automatically, set this to zero. The duration MUST be at least // MinAutoLogout (as stated in RFC 3501 section 5.4). @@ -122,10 +120,15 @@ type Server struct { // Create a new IMAP server from an existing listener. func New(bkd backend.Backend) *Server { s := &Server{ - listeners: make(map[net.Listener]struct{}), - conns: make(map[Conn]struct{}), - Backend: bkd, - ErrorLog: log.New(os.Stderr, "imap/server: ", log.LstdFlags), + listeners: make(map[net.Listener]struct{}), + conns: make(map[Conn]struct{}), + backendExts: map[backend.Extension]struct{}{}, + Backend: bkd, + ErrorLog: log.New(os.Stderr, "imap/server: ", log.LstdFlags), + } + + for _, ext := range bkd.SupportedExtensions() { + s.backendExts[ext] = struct{}{} } s.auths = map[string]SASLServerFactory{ @@ -203,12 +206,6 @@ func (s *Server) Serve(l net.Listener) error { delete(s.listeners, l) }() - updater, ok := s.Backend.(backend.BackendUpdater) - if ok { - s.Updates = updater.Updates() - go s.listenUpdates() - } - for { c, err := l.Accept() if err != nil { @@ -289,89 +286,6 @@ func (s *Server) Command(name string) HandlerFactory { return s.commands[name] } -func (s *Server) listenUpdates() { - for { - update := <-s.Updates - - var res imap.WriterTo - switch update := update.(type) { - case *backend.StatusUpdate: - res = update.StatusResp - case *backend.MailboxUpdate: - res = &responses.Select{Mailbox: update.MailboxStatus} - case *backend.MailboxInfoUpdate: - ch := make(chan *imap.MailboxInfo, 1) - ch <- update.MailboxInfo - close(ch) - - res = &responses.List{Mailboxes: ch} - case *backend.MessageUpdate: - ch := make(chan *imap.Message, 1) - ch <- update.Message - close(ch) - - res = &responses.Fetch{Messages: ch} - case *backend.ExpungeUpdate: - ch := make(chan uint32, 1) - ch <- update.SeqNum - close(ch) - - res = &responses.Expunge{SeqNums: ch} - default: - s.ErrorLog.Printf("unhandled update: %T\n", update) - } - if res == nil { - continue - } - - sends := make(chan struct{}) - wait := 0 - s.locker.Lock() - for conn := range s.conns { - ctx := conn.Context() - - if update.Username() != "" && (ctx.User == nil || ctx.User.Username() != update.Username()) { - continue - } - if update.Mailbox() != "" && (ctx.Mailbox == nil || ctx.Mailbox.Name() != update.Mailbox()) { - continue - } - if *conn.silent() { - // If silent is set, do not send message updates - if _, ok := res.(*responses.Fetch); ok { - continue - } - } - - conn := conn // Copy conn to a local variable - go func() { - done := make(chan struct{}) - conn.Context().Responses <- &response{ - response: res, - done: done, - } - <-done - sends <- struct{}{} - }() - - wait++ - } - s.locker.Unlock() - - if wait > 0 { - go func() { - for done := 0; done < wait; done++ { - <-sends - } - - close(update.Done()) - }() - } else { - close(update.Done()) - } - } -} - // ForEachConn iterates through all opened connections. func (s *Server) ForEachConn(f func(Conn)) { s.locker.Lock()