Skip to content

Commit 7dee64c

Browse files
committed
Mailbox attribute requirements from RFC 9051
Fixes #731
1 parent af9dbf1 commit 7dee64c

2 files changed

Lines changed: 194 additions & 1 deletion

File tree

Sources/NIOIMAPCore/Grammar/Mailbox/MailboxInfo.swift

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@ import struct OrderedCollections.OrderedDictionary
1717

1818
/// A collection of mailbox attributes defined in the supported IMAP4 RFCs.
1919
public struct MailboxInfo: Hashable, Sendable {
20-
/// An array of mailbox attributes.
20+
/// An array of mailbox attributes returned by the server.
21+
///
22+
/// Note: Servers may omit attributes that can be inferred from other returned attributes.
23+
/// Use ``hasEffectiveAttribute(_:)`` to check for an attribute while accounting for
24+
/// inference rules per RFC 9051 §6.3.9.4.
2125
public var attributes: [Attribute]
2226

2327
/// The mailbox path.
@@ -88,6 +92,23 @@ extension MailboxInfo {
8892
public func hash(into hasher: inout Hasher) {
8993
self.backing.lowercased().hash(into: &hasher)
9094
}
95+
96+
/// Returns whether this attribute implies another attribute per RFC 9051 §6.3.9.4.
97+
///
98+
/// The following inference rules are defined:
99+
///
100+
/// | Returned Attribute | Implied Attribute |
101+
/// |--------------------|-------------------|
102+
/// | `\NoInferiors` | `\HasNoChildren` |
103+
/// | `\NonExistent` | `\NoSelect` |
104+
public func implies(_ other: Self) -> Bool {
105+
switch (self, other) {
106+
case (.noInferiors, .hasNoChildren), (.nonExistent, .noSelect):
107+
true
108+
default:
109+
false
110+
}
111+
}
91112
}
92113
}
93114

@@ -98,6 +119,36 @@ extension String {
98119
}
99120
}
100121

122+
extension Sequence where Element == MailboxInfo.Attribute {
123+
/// Returns whether this sequence contains a given attribute either directly or by implication,
124+
/// per RFC 9051 §6.3.9.4.
125+
///
126+
/// The following inference rules are defined:
127+
///
128+
/// | Returned Attribute | Implied Attribute |
129+
/// |--------------------|-------------------|
130+
/// | `\NoInferiors` | `\HasNoChildren` |
131+
/// | `\NonExistent` | `\NoSelect` |
132+
public func containsEffective(_ attribute: MailboxInfo.Attribute) -> Bool {
133+
contains { $0 == attribute || $0.implies(attribute) }
134+
}
135+
}
136+
137+
extension MailboxInfo {
138+
/// Returns whether this mailbox's attributes include a given attribute either directly
139+
/// or by implication, per RFC 9051 §6.3.9.4.
140+
///
141+
/// The following inference rules are defined:
142+
///
143+
/// | Returned Attribute | Implied Attribute |
144+
/// |--------------------|-------------------|
145+
/// | `\NoInferiors` | `\HasNoChildren` |
146+
/// | `\NonExistent` | `\NoSelect` |
147+
public func hasEffectiveAttribute(_ attribute: Attribute) -> Bool {
148+
attributes.containsEffective(attribute)
149+
}
150+
}
151+
101152
// MARK: - Encoding
102153

103154
extension EncodeBuffer {

Tests/NIOIMAPCoreTests/Grammar/Mailbox/MailboxInfo+Tests.swift

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,51 @@ struct MailboxInfoTests {
2929
)
3030
}
3131

32+
@Test(
33+
"attribute implies",
34+
arguments: [
35+
ImpliesFixture(
36+
name: "NoInferiors implies HasNoChildren",
37+
attribute: .noInferiors,
38+
other: .hasNoChildren,
39+
expectation: true
40+
),
41+
ImpliesFixture(
42+
name: "NonExistent implies NoSelect",
43+
attribute: .nonExistent,
44+
other: .noSelect,
45+
expectation: true
46+
),
47+
ImpliesFixture(
48+
name: "HasNoChildren does NOT imply NoInferiors",
49+
attribute: .hasNoChildren,
50+
other: .noInferiors,
51+
expectation: false
52+
),
53+
ImpliesFixture(
54+
name: "NoSelect does NOT imply NonExistent",
55+
attribute: .noSelect,
56+
other: .nonExistent,
57+
expectation: false
58+
),
59+
ImpliesFixture(
60+
name: "Marked does NOT imply HasNoChildren",
61+
attribute: .marked,
62+
other: .hasNoChildren,
63+
expectation: false
64+
),
65+
ImpliesFixture(
66+
name: "NoInferiors (uppercase) implies HasNoChildren",
67+
attribute: MailboxInfo.Attribute(#"\NOINFERIORS"#),
68+
other: .hasNoChildren,
69+
expectation: true
70+
),
71+
]
72+
)
73+
func attributeImplies(_ fixture: ImpliesFixture) {
74+
#expect(fixture.attribute.implies(fixture.other) == fixture.expectation)
75+
}
76+
3277
@Test(arguments: [
3378
EncodeFixture.mailboxInfo(
3479
MailboxInfo(attributes: [], path: try! .init(name: .inbox), extensions: [:]),
@@ -153,6 +198,103 @@ struct MailboxInfoTests {
153198
func parseFlags(_ fixture: ParseFixture<[MailboxInfo.Attribute]>) {
154199
fixture.checkParsing()
155200
}
201+
202+
@Test(
203+
"sequence containsEffective",
204+
arguments: [
205+
ContainsEffectiveFixture(
206+
name: "empty array",
207+
attributes: [],
208+
query: .hasNoChildren,
209+
expectation: false
210+
),
211+
ContainsEffectiveFixture(
212+
name: "direct match",
213+
attributes: [.hasNoChildren],
214+
query: .hasNoChildren,
215+
expectation: true
216+
),
217+
ContainsEffectiveFixture(
218+
name: "NoInferiors contains HasNoChildren",
219+
attributes: [.noInferiors],
220+
query: .hasNoChildren,
221+
expectation: true
222+
),
223+
ContainsEffectiveFixture(
224+
name: "NonExistent contains NoSelect",
225+
attributes: [.nonExistent],
226+
query: .noSelect,
227+
expectation: true
228+
),
229+
ContainsEffectiveFixture(
230+
name: "HasNoChildren does NOT contain NoInferiors",
231+
attributes: [.hasNoChildren],
232+
query: .noInferiors,
233+
expectation: false
234+
),
235+
ContainsEffectiveFixture(
236+
name: "NoSelect does NOT contain NonExistent",
237+
attributes: [.noSelect],
238+
query: .nonExistent,
239+
expectation: false
240+
),
241+
ContainsEffectiveFixture(
242+
name: "multiple attributes with one triggering implication",
243+
attributes: [.marked, .nonExistent],
244+
query: .noSelect,
245+
expectation: true
246+
),
247+
ContainsEffectiveFixture(
248+
name: "case-insensitive implication check",
249+
attributes: [MailboxInfo.Attribute(#"\NOINFERIORS"#)],
250+
query: .hasNoChildren,
251+
expectation: true
252+
),
253+
]
254+
)
255+
func sequenceContainsEffective(_ fixture: ContainsEffectiveFixture) {
256+
#expect(fixture.attributes.containsEffective(fixture.query) == fixture.expectation)
257+
}
258+
259+
@Test("mailbox hasEffectiveAttribute")
260+
func mailboxHasEffectiveAttribute() {
261+
let mailbox1 = MailboxInfo(
262+
attributes: [.noInferiors],
263+
path: try! .init(name: .inbox),
264+
extensions: [:]
265+
)
266+
#expect(mailbox1.hasEffectiveAttribute(.hasNoChildren), "NoInferiors should imply HasNoChildren")
267+
#expect(!mailbox1.hasEffectiveAttribute(.marked), "should not have unrelated attribute")
268+
269+
let mailbox2 = MailboxInfo(
270+
attributes: [.noSelect],
271+
path: try! .init(name: .inbox),
272+
extensions: [:]
273+
)
274+
#expect(!mailbox2.hasEffectiveAttribute(.nonExistent), "NoSelect does NOT imply NonExistent")
275+
}
276+
}
277+
278+
// MARK: - Fixtures
279+
280+
extension MailboxInfoTests {
281+
struct ImpliesFixture: Sendable, CustomTestStringConvertible {
282+
var name: String
283+
var attribute: MailboxInfo.Attribute
284+
var other: MailboxInfo.Attribute
285+
var expectation: Bool
286+
287+
var testDescription: String { name }
288+
}
289+
290+
struct ContainsEffectiveFixture: Sendable, CustomTestStringConvertible {
291+
var name: String
292+
var attributes: [MailboxInfo.Attribute]
293+
var query: MailboxInfo.Attribute
294+
var expectation: Bool
295+
296+
var testDescription: String { name }
297+
}
156298
}
157299

158300
// MARK: -

0 commit comments

Comments
 (0)