Skip to content

Commit 71ac33c

Browse files
committed
Fix hasAttachment query for binary attachments
1 parent 69c33bd commit 71ac33c

3 files changed

Lines changed: 89 additions & 21 deletions

File tree

crates/email/src/message/index/search.rs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ use crate::message::{
88
index::{MAX_MESSAGE_PARTS, extractors::VisitTextArchived},
99
metadata::{
1010
ArchivedMessageMetadata, ArchivedMetadataHeaderName, ArchivedMetadataHeaderValue,
11-
ArchivedMetadataPartType, DecodedPartContent, MESSAGE_RECEIVED_MASK, MetadataHeaderName,
11+
ArchivedMetadataPartType, DecodedPartContent, MESSAGE_HAS_ATTACHMENT,
12+
MESSAGE_RECEIVED_MASK, MetadataHeaderName,
1213
},
1314
};
1415
use mail_parser::{DateTime, decoders::html::html_to_text, parsers::fields::thread::thread_name};
@@ -339,10 +340,17 @@ impl ArchivedMessageMetadata {
339340
#[cfg(feature = "test_mode")]
340341
document.set_unknown_language(default_language);
341342

342-
let has_attachment =
343-
document.has_field(&(SearchField::Email(EmailSearchField::Attachment)));
344-
343+
// `Email.hasAttachment` (RFC 8621) is defined as "message has one or more attachments".
344+
//
345+
// The indexing pipeline also builds a separate full-text `Attachment` field when it can
346+
// extract text from attachments (e.g. text/plain attachments, message/rfc822, etc.).
347+
// That *text index* is not equivalent to the existence of attachments.
348+
//
349+
// Use the metadata bit that is also used by Email/get, so Email/query hasAttachment filter
350+
// matches Email/get `hasAttachment`.
351+
let has_attachment = (self.rcvd_attach.to_native() & MESSAGE_HAS_ATTACHMENT) != 0;
345352
document.index_bool(EmailSearchField::HasAttachment, has_attachment);
353+
346354
document
347355
}
348356
}

crates/jmap-proto/src/request/method.rs

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,9 @@ impl MethodName {
122122
(MethodFunction::Query, MethodObject::Principal) => "Principal/query",
123123
(MethodFunction::Changes, MethodObject::Principal) => "Principal/changes",
124124
(MethodFunction::QueryChanges, MethodObject::Principal) => "Principal/queryChanges",
125-
(MethodFunction::GetAvailability, MethodObject::Principal) => "Principal/getAvailability",
125+
(MethodFunction::GetAvailability, MethodObject::Principal) => {
126+
"Principal/getAvailability"
127+
}
126128

127129
(MethodFunction::Get, MethodObject::Quota) => "Quota/get",
128130
(MethodFunction::Changes, MethodObject::Quota) => "Quota/changes",
@@ -153,9 +155,13 @@ impl MethodName {
153155
(MethodFunction::Set, MethodObject::FileNode) => "FileNode/set",
154156

155157
(MethodFunction::Get, MethodObject::ShareNotification) => "ShareNotification/get",
156-
(MethodFunction::Changes, MethodObject::ShareNotification) => "ShareNotification/changes",
158+
(MethodFunction::Changes, MethodObject::ShareNotification) => {
159+
"ShareNotification/changes"
160+
}
157161
(MethodFunction::Query, MethodObject::ShareNotification) => "ShareNotification/query",
158-
(MethodFunction::QueryChanges, MethodObject::ShareNotification) => "ShareNotification/queryChanges",
162+
(MethodFunction::QueryChanges, MethodObject::ShareNotification) => {
163+
"ShareNotification/queryChanges"
164+
}
159165
(MethodFunction::Set, MethodObject::ShareNotification) => "ShareNotification/set",
160166

161167
(MethodFunction::Get, MethodObject::Calendar) => "Calendar/get",
@@ -165,19 +171,33 @@ impl MethodName {
165171
(MethodFunction::Get, MethodObject::CalendarEvent) => "CalendarEvent/get",
166172
(MethodFunction::Changes, MethodObject::CalendarEvent) => "CalendarEvent/changes",
167173
(MethodFunction::Query, MethodObject::CalendarEvent) => "CalendarEvent/query",
168-
(MethodFunction::QueryChanges, MethodObject::CalendarEvent) => "CalendarEvent/queryChanges",
174+
(MethodFunction::QueryChanges, MethodObject::CalendarEvent) => {
175+
"CalendarEvent/queryChanges"
176+
}
169177
(MethodFunction::Set, MethodObject::CalendarEvent) => "CalendarEvent/set",
170178
(MethodFunction::Copy, MethodObject::CalendarEvent) => "CalendarEvent/copy",
171179
(MethodFunction::Parse, MethodObject::CalendarEvent) => "CalendarEvent/parse",
172180

173-
(MethodFunction::Get, MethodObject::CalendarEventNotification) => "CalendarEventNotification/get",
174-
(MethodFunction::Changes, MethodObject::CalendarEventNotification) => "CalendarEventNotification/changes",
175-
(MethodFunction::Query, MethodObject::CalendarEventNotification) => "CalendarEventNotification/query",
176-
(MethodFunction::QueryChanges, MethodObject::CalendarEventNotification) => "CalendarEventNotification/queryChanges",
177-
(MethodFunction::Set, MethodObject::CalendarEventNotification) => "CalendarEventNotification/set",
181+
(MethodFunction::Get, MethodObject::CalendarEventNotification) => {
182+
"CalendarEventNotification/get"
183+
}
184+
(MethodFunction::Changes, MethodObject::CalendarEventNotification) => {
185+
"CalendarEventNotification/changes"
186+
}
187+
(MethodFunction::Query, MethodObject::CalendarEventNotification) => {
188+
"CalendarEventNotification/query"
189+
}
190+
(MethodFunction::QueryChanges, MethodObject::CalendarEventNotification) => {
191+
"CalendarEventNotification/queryChanges"
192+
}
193+
(MethodFunction::Set, MethodObject::CalendarEventNotification) => {
194+
"CalendarEventNotification/set"
195+
}
178196

179197
(MethodFunction::Get, MethodObject::ParticipantIdentity) => "ParticipantIdentity/get",
180-
(MethodFunction::Changes, MethodObject::ParticipantIdentity) => "ParticipantIdentity/changes",
198+
(MethodFunction::Changes, MethodObject::ParticipantIdentity) => {
199+
"ParticipantIdentity/changes"
200+
}
181201
(MethodFunction::Set, MethodObject::ParticipantIdentity) => "ParticipantIdentity/set",
182202

183203
(MethodFunction::Echo, MethodObject::Core) => "Core/echo",
@@ -186,7 +206,7 @@ impl MethodName {
186206
}
187207

188208
pub fn parse(s: &str) -> Option<Self> {
189-
hashify::tiny_map!(s.as_bytes(),
209+
hashify::tiny_map!(s.as_bytes(),
190210
"PushSubscription/get" => (MethodObject::PushSubscription, MethodFunction::Get),
191211
"PushSubscription/set" => (MethodObject::PushSubscription, MethodFunction::Set),
192212

@@ -295,7 +315,6 @@ impl MethodName {
295315

296316
).map(|(obj, fnc)| MethodName { obj, fnc })
297317
}
298-
299318
}
300319

301320
impl Display for MethodObject {
@@ -326,17 +345,15 @@ impl Display for MethodObject {
326345
}
327346
}
328347

329-
330348
impl<'de> serde::Deserialize<'de> for MethodName {
331349
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
332350
where
333351
D: serde::Deserializer<'de>,
334352
{
335353
let value = <&str>::deserialize(deserializer)?;
336354

337-
MethodName::parse(value).ok_or_else(|| {
338-
serde::de::Error::custom(format!("Invalid method name: {:?}", value))
339-
})
355+
MethodName::parse(value)
356+
.ok_or_else(|| serde::de::Error::custom(format!("Invalid method name: {:?}", value)))
340357
}
341358
}
342359

tests/src/jmap/mail/query.rs

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,51 @@ pub async fn test(params: &mut JMAPTest, insert: bool) {
8888
MAX_THREADS
8989
);
9090

91-
// Wait for indexing to complete
91+
// Regression test: Email/query filter {hasAttachment:true} should match emails with binary
92+
// attachments (not just those with attachment text indexed).
93+
let sent_at = now();
94+
let binary_anchor = "stalwart-hasattachment-binary";
95+
let binary_message = MessageBuilder::new()
96+
.from(("Sender", "sender@domain.com"))
97+
.to(("Recipient", "rcpt@domain.com"))
98+
.subject("Binary attachment regression")
99+
.date(Date::new(sent_at as i64 + 10_000))
100+
.message_id(binary_anchor)
101+
.text_body("binary attachment")
102+
.attachment("application/octet-stream", "file.bin", &[0u8; 8][..])
103+
.write_to_vec()
104+
.unwrap();
105+
106+
client
107+
.email_import(
108+
binary_message,
109+
[Id::new(2000u64).to_string()],
110+
Vec::<String>::new().into(),
111+
Some(sent_at as i64 + 10_000),
112+
)
113+
.await
114+
.unwrap();
115+
116+
// Wait for indexing to complete (covers both the bulk import and the regression message).
92117
wait_for_index(&server).await;
118+
119+
let binary_email_id = get_anchor(client, binary_anchor)
120+
.await
121+
.expect("binary regression email should be findable by Message-Id");
122+
123+
let ids = client
124+
.email_query(
125+
email::query::Filter::has_attachment(true).into(),
126+
None::<Vec<_>>,
127+
)
128+
.await
129+
.unwrap()
130+
.take_ids();
131+
132+
assert!(
133+
ids.iter().any(|id| id.to_string() == binary_email_id),
134+
"expected hasAttachment query to include imported binary attachment message"
135+
);
93136
}
94137

95138
let can_stem = !params.server.search_store().is_mysql();

0 commit comments

Comments
 (0)