Other: Implement subfolder support

This commit is contained in:
James Houlahan
2022-10-25 02:04:43 +02:00
parent 4f7cb43c8f
commit fd0c262645
3 changed files with 249 additions and 42 deletions

View File

@ -31,6 +31,8 @@ import (
"github.com/ProtonMail/proton-bridge/v2/internal/vault"
"github.com/ProtonMail/proton-bridge/v2/pkg/message"
"github.com/bradenaw/juniper/stream"
"github.com/bradenaw/juniper/xslices"
"github.com/google/go-cmp/cmp"
"gitlab.protontech.ch/go/liteapi"
"golang.org/x/exp/slices"
)
@ -82,7 +84,7 @@ func (conn *imapConnector) Authorize(username string, password []byte) bool {
// GetMailbox returns information about the mailbox with the given ID.
func (conn *imapConnector) GetMailbox(ctx context.Context, mailboxID imap.MailboxID) (imap.Mailbox, error) {
label, err := conn.client.GetLabel(ctx, string(mailboxID), liteapi.LabelTypeLabel, liteapi.LabelTypeFolder)
label, err := conn.client.GetLabel(ctx, string(mailboxID), liteapi.LabelTypeLabel, liteapi.LabelTypeFolder, liteapi.LabelTypeSystem)
if err != nil {
return imap.Mailbox{}, err
}
@ -92,22 +94,64 @@ func (conn *imapConnector) GetMailbox(ctx context.Context, mailboxID imap.Mailbo
// CreateMailbox creates a label with the given name.
func (conn *imapConnector) CreateMailbox(ctx context.Context, name []string) (imap.Mailbox, error) {
if len(name) != 2 {
panic("subfolders are unsupported")
if len(name) < 2 {
return imap.Mailbox{}, fmt.Errorf("invalid mailbox name %q", name)
}
var labelType liteapi.LabelType
switch name[0] {
case folderPrefix:
return conn.createFolder(ctx, name[1:])
if name[0] == folderPrefix {
labelType = liteapi.LabelTypeFolder
} else {
labelType = liteapi.LabelTypeLabel
case labelPrefix:
return conn.createLabel(ctx, name[1:])
default:
return imap.Mailbox{}, fmt.Errorf("invalid mailbox name %q", name)
}
}
func (conn *imapConnector) createLabel(ctx context.Context, name []string) (imap.Mailbox, error) {
if len(name) != 1 {
return imap.Mailbox{}, fmt.Errorf("a label cannot have children")
}
label, err := conn.client.CreateLabel(ctx, liteapi.CreateLabelReq{
Name: name[1:][0],
Name: name[0],
Color: "#f66",
Type: labelType,
Type: liteapi.LabelTypeLabel,
})
if err != nil {
return imap.Mailbox{}, err
}
return toIMAPMailbox(label, conn.flags, conn.permFlags, conn.attrs), nil
}
func (conn *imapConnector) createFolder(ctx context.Context, name []string) (imap.Mailbox, error) {
var parentID string
if len(name) > 1 {
folders, err := conn.client.GetLabels(ctx, liteapi.LabelTypeFolder)
if err != nil {
return imap.Mailbox{}, err
}
idx := xslices.IndexFunc(folders, func(folder liteapi.Label) bool {
return cmp.Equal(folder.Path, name[:len(name)-1])
})
if idx < 0 {
return imap.Mailbox{}, fmt.Errorf("parent folder %q does not exist", name[:len(name)-1])
}
parentID = folders[idx].ID
}
label, err := conn.client.CreateLabel(ctx, liteapi.CreateLabelReq{
Name: name[len(name)-1],
Color: "#f66",
Type: liteapi.LabelTypeFolder,
ParentID: parentID,
})
if err != nil {
return imap.Mailbox{}, err
@ -117,37 +161,72 @@ func (conn *imapConnector) CreateMailbox(ctx context.Context, name []string) (im
}
// UpdateMailboxName sets the name of the label with the given ID.
func (conn *imapConnector) UpdateMailboxName(ctx context.Context, labelID imap.MailboxID, newName []string) error {
if len(newName) != 2 {
panic("subfolders are unsupported")
func (conn *imapConnector) UpdateMailboxName(ctx context.Context, labelID imap.MailboxID, name []string) error {
if len(name) < 2 {
return fmt.Errorf("invalid mailbox name %q", name)
}
label, err := conn.client.GetLabel(ctx, string(labelID), liteapi.LabelTypeLabel, liteapi.LabelTypeFolder)
switch name[0] {
case folderPrefix:
return conn.updateFolder(ctx, labelID, name[1:])
case labelPrefix:
return conn.updateLabel(ctx, labelID, name[1:])
default:
return fmt.Errorf("invalid mailbox name %q", name)
}
}
func (conn *imapConnector) updateLabel(ctx context.Context, labelID imap.MailboxID, name []string) error {
if len(name) != 1 {
return fmt.Errorf("a label cannot have children")
}
label, err := conn.client.GetLabel(ctx, string(labelID), liteapi.LabelTypeLabel)
if err != nil {
return err
}
switch label.Type {
case liteapi.LabelTypeFolder:
if newName[0] != folderPrefix {
return fmt.Errorf("cannot rename folder to label")
}
case liteapi.LabelTypeLabel:
if newName[0] != labelPrefix {
return fmt.Errorf("cannot rename label to folder")
}
case liteapi.LabelTypeSystem:
return fmt.Errorf("cannot rename system label %q", label.Name)
case liteapi.LabelTypeContactGroup:
return fmt.Errorf("cannot rename contact group label %q", label.Name)
if _, err := conn.client.UpdateLabel(ctx, label.ID, liteapi.UpdateLabelReq{
Name: name[0],
Color: label.Color,
}); err != nil {
return err
}
if _, err := conn.client.UpdateLabel(ctx, label.ID, liteapi.UpdateLabelReq{
Name: newName[1:][0],
Color: label.Color,
return nil
}
func (conn *imapConnector) updateFolder(ctx context.Context, labelID imap.MailboxID, name []string) error {
var parentID string
if len(name) > 1 {
folders, err := conn.client.GetLabels(ctx, liteapi.LabelTypeFolder)
if err != nil {
return err
}
idx := xslices.IndexFunc(folders, func(folder liteapi.Label) bool {
return cmp.Equal(folder.Path, name[:len(name)-1])
})
if idx < 0 {
return fmt.Errorf("parent folder %q does not exist", name[:len(name)-1])
}
parentID = folders[idx].ID
}
label, err := conn.client.GetLabel(ctx, string(labelID), liteapi.LabelTypeFolder)
if err != nil {
return err
}
if _, err := conn.client.UpdateLabel(ctx, string(labelID), liteapi.UpdateLabelReq{
Name: name[len(name)-1],
Color: label.Color,
ParentID: parentID,
}); err != nil {
return err
}
@ -171,8 +250,6 @@ func (conn *imapConnector) GetMessage(ctx context.Context, messageID imap.Messag
}
// CreateMessage creates a new message on the remote.
//
// nolint:funlen
func (conn *imapConnector) CreateMessage(
ctx context.Context,
mailboxID imap.MailboxID,
@ -362,17 +439,15 @@ func toIMAPMessage(message liteapi.MessageMetadata) imap.Message {
}
func toIMAPMailbox(label liteapi.Label, flags, permFlags, attrs imap.FlagSet) imap.Mailbox {
var name []string
if label.Type == liteapi.LabelTypeLabel {
name = append(name, labelPrefix)
label.Path = append([]string{labelPrefix}, label.Path...)
} else if label.Type == liteapi.LabelTypeFolder {
name = append(name, folderPrefix)
label.Path = append([]string{folderPrefix}, label.Path...)
}
return imap.Mailbox{
ID: imap.MailboxID(label.ID),
Name: append(name, label.Name),
Name: label.Path,
Flags: flags,
PermanentFlags: permFlags,
Attributes: attrs,

View File

@ -51,3 +51,135 @@ Feature: IMAP create mailbox
| Labels/l1 |
| Labels/l2 |
| Labels/l3 |
Scenario: Creating subfolders is possible and they persist after resync
When IMAP client "1" creates "Folders/f1/f11"
Then it succeeds
When IMAP client "1" creates "Folders/f1/f12"
Then it succeeds
When IMAP client "1" creates "Folders/f2/f21"
Then it succeeds
When IMAP client "1" creates "Folders/f2/f22"
Then it succeeds
Then IMAP client "1" sees the following mailbox info:
| name |
| INBOX |
| Drafts |
| Sent |
| Starred |
| Archive |
| Spam |
| Trash |
| All Mail |
| Folders |
| Folders/f1 |
| Folders/f1/f11 |
| Folders/f1/f12 |
| Folders/f2 |
| Folders/f2/f21 |
| Folders/f2/f22 |
| Labels |
| Labels/l1 |
| Labels/l2 |
When user "user@pm.me" is deleted
And the user logs in with username "user@pm.me" and password "password"
And user "user@pm.me" finishes syncing
And user "user@pm.me" connects and authenticates IMAP client "2"
Then IMAP client "2" sees the following mailbox info:
| name |
| INBOX |
| Drafts |
| Sent |
| Starred |
| Archive |
| Spam |
| Trash |
| All Mail |
| Folders |
| Folders/f1 |
| Folders/f1/f11 |
| Folders/f1/f12 |
| Folders/f2 |
| Folders/f2/f21 |
| Folders/f2/f22 |
| Labels |
| Labels/l1 |
| Labels/l2 |
Scenario: Changing folder parent is possible and it persists after resync
When IMAP client "1" creates "Folders/f1/f11"
Then it succeeds
When IMAP client "1" creates "Folders/f1/f12"
Then it succeeds
When IMAP client "1" creates "Folders/f2/f21"
Then it succeeds
When IMAP client "1" creates "Folders/f2/f22"
Then it succeeds
Then IMAP client "1" sees the following mailbox info:
| name |
| INBOX |
| Drafts |
| Sent |
| Starred |
| Archive |
| Spam |
| Trash |
| All Mail |
| Folders |
| Folders/f1 |
| Folders/f1/f11 |
| Folders/f1/f12 |
| Folders/f2 |
| Folders/f2/f21 |
| Folders/f2/f22 |
| Labels |
| Labels/l1 |
| Labels/l2 |
When IMAP client "1" renames "Folders/f1/f11" to "Folders/f2/f11"
Then it succeeds
When IMAP client "1" renames "Folders/f1/f12" to "Folders/f2/f12"
Then it succeeds
Then IMAP client "1" sees the following mailbox info:
| name |
| INBOX |
| Drafts |
| Sent |
| Starred |
| Archive |
| Spam |
| Trash |
| All Mail |
| Folders |
| Folders/f1 |
| Folders/f2 |
| Folders/f2/f11 |
| Folders/f2/f12 |
| Folders/f2/f21 |
| Folders/f2/f22 |
| Labels |
| Labels/l1 |
| Labels/l2 |
When user "user@pm.me" is deleted
And the user logs in with username "user@pm.me" and password "password"
And user "user@pm.me" finishes syncing
And user "user@pm.me" connects and authenticates IMAP client "2"
Then IMAP client "2" sees the following mailbox info:
| name |
| INBOX |
| Drafts |
| Sent |
| Starred |
| Archive |
| Spam |
| Trash |
| All Mail |
| Folders |
| Folders/f1 |
| Folders/f2 |
| Folders/f2/f11 |
| Folders/f2/f12 |
| Folders/f2/f21 |
| Folders/f2/f22 |
| Labels |
| Labels/l1 |
| Labels/l2 |

View File

@ -160,7 +160,7 @@ func matchMailboxes(have, want []Mailbox) error {
})
if !IsSub(want, have) {
return fmt.Errorf("missing messages: %v", want)
return fmt.Errorf("missing mailboxes: %v", want)
}
return nil