Skip to content

Commit dcaa99a

Browse files
committed
implement IMAP CREATE-SPECIAL-USE extension for the mailbox create command, part of rfc 6154
we already supported special-use flags. settable through the webmail interface, and new accounts already got standard mailboxes with special-use flags predefined. but now the IMAP "CREATE" command implements creating mailboxes with special-use flags.
1 parent 7288e03 commit dcaa99a

File tree

15 files changed

+167
-58
lines changed

15 files changed

+167
-58
lines changed

imapclient/client.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ type Conn struct {
3434

3535
Preauth bool
3636
LastTag string
37-
CapAvailable map[Capability]struct{} // Capabilities available at server, from CAPABILITY command or response code.
38-
CapEnabled map[Capability]struct{} // Capabilities enabled through ENABLE command.
37+
CapAvailable map[Capability]struct{} // Capabilities available at server, from CAPABILITY command or response code. All uppercase.
38+
CapEnabled map[Capability]struct{} // Capabilities enabled through ENABLE command. All uppercase.
3939
}
4040

4141
// Error is a parse or other protocol error.

imapclient/cmds.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,9 +149,18 @@ func (c *Conn) Examine(mailbox string) (untagged []Untagged, result Result, rerr
149149
}
150150

151151
// Create makes a new mailbox on the server.
152-
func (c *Conn) Create(mailbox string) (untagged []Untagged, result Result, rerr error) {
152+
// SpecialUse can only be used on servers that announced the CREATE-SPECIAL-USE
153+
// capability. Specify flags like \Archive, \Draft, \Junk, \Sent, \Trash, \All.
154+
func (c *Conn) Create(mailbox string, specialUse []string) (untagged []Untagged, result Result, rerr error) {
153155
defer c.recover(&rerr)
154-
return c.Transactf("create %s", astring(mailbox))
156+
if _, ok := c.CapAvailable[CapCreateSpecialUse]; !ok && len(specialUse) > 0 {
157+
c.xerrorf("server does not implement create-special-use extension")
158+
}
159+
var useStr string
160+
if len(specialUse) > 0 {
161+
useStr = fmt.Sprintf(" USE (%s)", strings.Join(specialUse, " "))
162+
}
163+
return c.Transactf("create %s%s", astring(mailbox), useStr)
155164
}
156165

157166
// Delete removes an entire mailbox and its messages.

imapclient/parse.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ func (c *Conn) xrespCode() (string, CodeArg) {
181181
}
182182
c.CapAvailable = map[Capability]struct{}{}
183183
for _, cap := range caps {
184+
cap = strings.ToUpper(cap)
184185
c.CapAvailable[Capability(cap)] = struct{}{}
185186
}
186187
codeArg = CodeWords{W, caps}
@@ -343,6 +344,7 @@ func (c *Conn) xuntagged() Untagged {
343344
}
344345
c.CapAvailable = map[Capability]struct{}{}
345346
for _, cap := range caps {
347+
cap = strings.ToUpper(cap)
346348
c.CapAvailable[Capability(cap)] = struct{}{}
347349
}
348350
r := UntaggedCapability(caps)
@@ -356,6 +358,7 @@ func (c *Conn) xuntagged() Untagged {
356358
caps = append(caps, c.xnonspace())
357359
}
358360
for _, cap := range caps {
361+
cap = strings.ToUpper(cap)
359362
c.CapEnabled[Capability(cap)] = struct{}{}
360363
}
361364
r := UntaggedEnabled(caps)

imapclient/protocol.go

Lines changed: 25 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -11,29 +11,31 @@ import (
1111
type Capability string
1212

1313
const (
14-
CapIMAP4rev1 Capability = "IMAP4rev1"
15-
CapIMAP4rev2 Capability = "IMAP4rev2"
16-
CapLoginDisabled Capability = "LOGINDISABLED"
17-
CapStarttls Capability = "STARTTLS"
18-
CapAuthPlain Capability = "AUTH=PLAIN"
19-
CapLiteralPlus Capability = "LITERAL+"
20-
CapLiteralMinus Capability = "LITERAL-"
21-
CapIdle Capability = "IDLE"
22-
CapNamespace Capability = "NAMESPACE"
23-
CapBinary Capability = "BINARY"
24-
CapUnselect Capability = "UNSELECT"
25-
CapUidplus Capability = "UIDPLUS"
26-
CapEsearch Capability = "ESEARCH"
27-
CapEnable Capability = "ENABLE"
28-
CapSave Capability = "SAVE"
29-
CapListExtended Capability = "LIST-EXTENDED"
30-
CapSpecialUse Capability = "SPECIAL-USE"
31-
CapMove Capability = "MOVE"
32-
CapUTF8Only Capability = "UTF8=ONLY"
33-
CapUTF8Accept Capability = "UTF8=ACCEPT"
34-
CapID Capability = "ID" // ../rfc/2971:80
35-
CapMetadata Capability = "METADATA" // ../rfc/5464:124
36-
CapMetadataServer Capability = "METADATA-SERVER" // ../rfc/5464:124
14+
CapIMAP4rev1 Capability = "IMAP4rev1"
15+
CapIMAP4rev2 Capability = "IMAP4rev2"
16+
CapLoginDisabled Capability = "LOGINDISABLED"
17+
CapStarttls Capability = "STARTTLS"
18+
CapAuthPlain Capability = "AUTH=PLAIN"
19+
CapLiteralPlus Capability = "LITERAL+"
20+
CapLiteralMinus Capability = "LITERAL-"
21+
CapIdle Capability = "IDLE"
22+
CapNamespace Capability = "NAMESPACE"
23+
CapBinary Capability = "BINARY"
24+
CapUnselect Capability = "UNSELECT"
25+
CapUidplus Capability = "UIDPLUS"
26+
CapEsearch Capability = "ESEARCH"
27+
CapEnable Capability = "ENABLE"
28+
CapSave Capability = "SAVE"
29+
CapListExtended Capability = "LIST-EXTENDED"
30+
CapSpecialUse Capability = "SPECIAL-USE"
31+
CapMove Capability = "MOVE"
32+
CapUTF8Only Capability = "UTF8=ONLY"
33+
CapUTF8Accept Capability = "UTF8=ACCEPT"
34+
CapID Capability = "ID" // ../rfc/2971:80
35+
CapMetadata Capability = "METADATA" // ../rfc/5464:124
36+
CapMetadataServer Capability = "METADATA-SERVER" // ../rfc/5464:124
37+
CapSaveDate Capability = "SAVEDATE" // ../rfc/8514
38+
CapCreateSpecialUse Capability = "CREATE-SPECIAL-USE" // ../rfc/6154:296
3739
)
3840

3941
// Status is the tagged final result of a command.

imapserver/condstore_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ func testCondstoreQresync(t *testing.T, qresync bool) {
9696
err = tc.account.DB.Update(ctxbg, &store.SyncState{ID: 1, LastModSeq: 1})
9797
tcheck(t, err, "resetting modseq state")
9898

99-
tc.client.Create("otherbox")
99+
tc.client.Create("otherbox", nil)
100100

101101
// tc2 is a client without condstore, so no modseq responses.
102102
tc2 := startNoSwitchboard(t)

imapserver/create_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,14 @@ func TestCreate(t *testing.T) {
7272
tc.transactf("no", `create "#"`) // Leading hash not allowed.
7373
tc.transactf("ok", `create "test#"`)
7474

75+
// Create with flags.
76+
tc.transactf("no", `create "newwithflags" (use (\unknown))`)
77+
tc.transactf("no", `create "newwithflags" (use (\all))`)
78+
tc.transactf("ok", `create "newwithflags" (use (\archive))`)
79+
tc.transactf("ok", "noop")
80+
tc.xuntagged()
81+
tc.transactf("ok", `create "newwithflags2" (use (\archive) use (\drafts \sent))`)
82+
7583
// UTF-7 checks are only for IMAP4 before rev2 and without UTF8=ACCEPT.
7684
tc.transactf("ok", `create "&"`) // Interpreted as UTF-8, no UTF-7.
7785
tc2.transactf("bad", `create "&"`) // Bad UTF-7.

imapserver/delete_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ func TestDelete(t *testing.T) {
2828
tc.client.Subscribe("x")
2929
tc.transactf("no", "delete x") // Subscription does not mean there is a mailbox that can be deleted.
3030

31-
tc.client.Create("a/b")
31+
tc.client.Create("a/b", nil)
3232
tc2.transactf("ok", "noop") // Drain changes.
3333
tc3.transactf("ok", "noop")
3434

@@ -53,12 +53,12 @@ func TestDelete(t *testing.T) {
5353
)
5454

5555
// Let's try again with a message present.
56-
tc.client.Create("msgs")
56+
tc.client.Create("msgs", nil)
5757
tc.client.Append("msgs", nil, nil, []byte(exampleMsg))
5858
tc.transactf("ok", "delete msgs")
5959

6060
// Delete for inbox/* is allowed.
61-
tc.client.Create("inbox/a")
61+
tc.client.Create("inbox/a", nil)
6262
tc.transactf("ok", "delete inbox/a")
6363

6464
}

imapserver/list_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ func TestListBasic(t *testing.T) {
3535
tc.last(tc.client.List("A*"))
3636
tc.xuntagged(ulist("Archive", `\Archive`))
3737

38-
tc.client.Create("Inbox/todo")
38+
tc.client.Create("Inbox/todo", nil)
3939

4040
tc.last(tc.client.List("Inbox*"))
4141
tc.xuntagged(ulist("Inbox"), ulist("Inbox/todo"))
@@ -146,7 +146,7 @@ func TestListExtended(t *testing.T) {
146146
tc.last(tc.client.ListFull(false, "A*", "Junk"))
147147
tc.xuntagged(xlist("Archive", Farchive), ustatus("Archive"), xlist("Junk", Fjunk), ustatus("Junk"))
148148

149-
tc.client.Create("Inbox/todo")
149+
tc.client.Create("Inbox/todo", nil)
150150

151151
tc.last(tc.client.ListFull(false, "Inbox*"))
152152
tc.xuntagged(ulist("Inbox", Fhaschildren, Fsubscribed), ustatus("Inbox"), xlist("Inbox/todo"), ustatus("Inbox/todo"))
@@ -204,7 +204,7 @@ func TestListExtended(t *testing.T) {
204204
tc.transactf("ok", `list (remote) "inbox" "a"`)
205205
tc.xuntagged()
206206

207-
tc.client.Create("inbox/a")
207+
tc.client.Create("inbox/a", nil)
208208
tc.transactf("ok", `list (remote) "inbox" "a"`)
209209
tc.xuntagged(ulist("Inbox/a"))
210210

imapserver/rename_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@ func TestRename(t *testing.T) {
2626
tc.transactf("no", `rename "Sent" "Trash"`) // Already exists.
2727
tc.xcode("ALREADYEXISTS")
2828

29-
tc.client.Create("x")
29+
tc.client.Create("x", nil)
3030
tc.client.Subscribe("sub")
31-
tc.client.Create("a/b/c")
31+
tc.client.Create("a/b/c", nil)
3232
tc.client.Subscribe("x/y/c") // For later rename, but not affected by rename of x.
3333
tc2.transactf("ok", "noop") // Drain.
3434

@@ -58,7 +58,7 @@ func TestRename(t *testing.T) {
5858
tc2.transactf("ok", "noop")
5959
tc2.xuntagged(imapclient.UntaggedList{Flags: []string{`\Subscribed`}, Separator: '/', Mailbox: "x"}, imapclient.UntaggedList{Separator: '/', Mailbox: "x/y", OldName: "a/b"}, imapclient.UntaggedList{Flags: []string{`\Subscribed`}, Separator: '/', Mailbox: "x/y/c", OldName: "a/b/c"})
6060

61-
tc.client.Create("k/l")
61+
tc.client.Create("k/l", nil)
6262
tc.transactf("ok", "rename k/l k/l/m") // With "l" renamed, a new "k" will be created.
6363
tc.transactf("ok", `list "" "k*" return (subscribed)`)
6464
tc.xuntagged(imapclient.UntaggedList{Flags: []string{`\Subscribed`}, Separator: '/', Mailbox: "k"}, imapclient.UntaggedList{Flags: []string{`\Subscribed`}, Separator: '/', Mailbox: "k/l"}, imapclient.UntaggedList{Separator: '/', Mailbox: "k/l/m"})

imapserver/server.go

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ non-ASCII UTF-8. Until that's enabled, we do use UTF-7 for mailbox names. See
2828
- todo: do not return binary data for a fetch body. at least not for imap4rev1. we should be encoding it as base64?
2929
- todo: on expunge we currently remove the message even if other sessions still have a reference to the uid. if they try to query the uid, they'll get an error. we could be nicer and only actually remove the message when the last reference has gone. we could add a new flag to store.Message marking the message as expunged, not give new session access to such messages, and make store remove them at startup, and clean them when the last session referencing the session goes. however, it will get much more complicated. renaming messages would need special handling. and should we do the same for removed mailboxes?
3030
- todo: try to recover from syntax errors when the last command line ends with a }, i.e. a literal. we currently abort the entire connection. we may want to read some amount of literal data and continue with a next command.
31-
- todo future: more extensions: OBJECTID, MULTISEARCH, REPLACE, NOTIFY, CATENATE, MULTIAPPEND, SORT, THREAD, CREATE-SPECIAL-USE.
31+
- todo future: more extensions: OBJECTID, MULTISEARCH, REPLACE, NOTIFY, CATENATE, MULTIAPPEND, SORT, THREAD.
3232
*/
3333

3434
import (
@@ -146,7 +146,7 @@ var authFailDelay = time.Second // After authentication failure.
146146
// MOVE: ../rfc/6851
147147
// UTF8=ONLY: ../rfc/6855
148148
// LIST-EXTENDED: ../rfc/5258
149-
// SPECIAL-USE: ../rfc/6154
149+
// SPECIAL-USE CREATE-SPECIAL-USE: ../rfc/6154
150150
// LIST-STATUS: ../rfc/5819
151151
// ID: ../rfc/2971
152152
// AUTH=EXTERNAL: ../rfc/4422:1575
@@ -165,7 +165,7 @@ var authFailDelay = time.Second // After authentication failure.
165165
// TLS. The client should not be selecting PLUS variants on non-TLS connections,
166166
// instead opting to do the bare SCRAM variant without indicating the server claims
167167
// to support the PLUS variant (skipping the server downgrade detection check).
168-
const serverCapabilities = "IMAP4rev2 IMAP4rev1 ENABLE LITERAL+ IDLE SASL-IR BINARY UNSELECT UIDPLUS ESEARCH SEARCHRES MOVE UTF8=ACCEPT LIST-EXTENDED SPECIAL-USE LIST-STATUS AUTH=SCRAM-SHA-256-PLUS AUTH=SCRAM-SHA-256 AUTH=SCRAM-SHA-1-PLUS AUTH=SCRAM-SHA-1 AUTH=CRAM-MD5 ID APPENDLIMIT=9223372036854775807 CONDSTORE QRESYNC STATUS=SIZE QUOTA QUOTA=RES-STORAGE METADATA SAVEDATE"
168+
const serverCapabilities = "IMAP4rev2 IMAP4rev1 ENABLE LITERAL+ IDLE SASL-IR BINARY UNSELECT UIDPLUS ESEARCH SEARCHRES MOVE UTF8=ACCEPT LIST-EXTENDED SPECIAL-USE CREATE-SPECIAL-USE LIST-STATUS AUTH=SCRAM-SHA-256-PLUS AUTH=SCRAM-SHA-256 AUTH=SCRAM-SHA-1-PLUS AUTH=SCRAM-SHA-1 AUTH=CRAM-MD5 ID APPENDLIMIT=9223372036854775807 CONDSTORE QRESYNC STATUS=SIZE QUOTA QUOTA=RES-STORAGE METADATA SAVEDATE"
169169

170170
type conn struct {
171171
cid int64
@@ -2710,21 +2710,58 @@ func (c *conn) cmdCreate(tag, cmd string, p *parser) {
27102710
// Request syntax: ../rfc/9051:6484 ../rfc/6154:468 ../rfc/4466:500 ../rfc/3501:4687
27112711
p.xspace()
27122712
name := p.xmailbox()
2713-
// todo: support CREATE-SPECIAL-USE ../rfc/6154:296
2713+
// Optional parameters. ../rfc/4466:501 ../rfc/4466:511
2714+
var useAttrs []string // Special-use attributes without leading \.
2715+
if p.space() {
2716+
p.xtake("(")
2717+
// We only support "USE", and there don't appear to be more types of parameters.
2718+
for {
2719+
p.xtake("USE (")
2720+
for {
2721+
p.xtake(`\`)
2722+
useAttrs = append(useAttrs, p.xatom())
2723+
if !p.space() {
2724+
break
2725+
}
2726+
}
2727+
p.xtake(")")
2728+
if !p.space() {
2729+
break
2730+
}
2731+
}
2732+
p.xtake(")")
2733+
}
27142734
p.xempty()
27152735

27162736
origName := name
27172737
name = strings.TrimRight(name, "/") // ../rfc/9051:1930
27182738
name = xcheckmailboxname(name, false)
27192739

2740+
var specialUse store.SpecialUse
2741+
specialUseBools := map[string]*bool{
2742+
"archive": &specialUse.Archive,
2743+
"drafts": &specialUse.Draft,
2744+
"junk": &specialUse.Junk,
2745+
"sent": &specialUse.Sent,
2746+
"trash": &specialUse.Trash,
2747+
}
2748+
for _, s := range useAttrs {
2749+
p, ok := specialUseBools[strings.ToLower(s)]
2750+
if !ok {
2751+
// ../rfc/6154:287
2752+
xusercodeErrorf("USEATTR", `cannot create mailbox with special-use attribute \%s`, s)
2753+
}
2754+
*p = true
2755+
}
2756+
27202757
var changes []store.Change
27212758
var created []string // Created mailbox names.
27222759

27232760
c.account.WithWLock(func() {
27242761
c.xdbwrite(func(tx *bstore.Tx) {
27252762
var exists bool
27262763
var err error
2727-
changes, created, exists, err = c.account.MailboxCreate(tx, name)
2764+
changes, created, exists, err = c.account.MailboxCreate(tx, name, specialUse)
27282765
if exists {
27292766
// ../rfc/9051:1914
27302767
xuserErrorf("mailbox already exists")

0 commit comments

Comments
 (0)