Merge upstream v0.16.27 #204

Merged
jcoffey-dev merged 4 commits from merge/upstream-v0.16.27 into main 2026-10-18 21:39:46 +00:00
56 changed files with 731 additions and 265 deletions

No files matched your search

+17
View File
@@ -2,6 +2,22 @@
All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/).
## [0.16.27] - 2026-10-18
If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions.
## Added
## Changed
## Fixed
- IMAP:
- `APPEND` and `COPY`/`MOVE` into another account's mailbox do not change the JMAP `EmailDelivery` state.
- `SEARCH RETURN` with `MIN` or `MAX` alongside `ALL` returns only the minimum and maximum in `ALL`, and `SAVE` combined with `MIN` or `MAX` and `ALL` or `COUNT` saves only the minimum and maximum instead of every match.
- iTIP: Merging a received update does not copy the `VTIMEZONE` definitions it carries, so an occurrence moved by an external organizer can reference a `TZID` (for example `W. Europe Standard Time`) the stored event no longer defines, and Apple Calendar shows both the original and the moved occurrence.
- OAuth: Loopback redirect URIs without a path (for example `http://127.0.0.1:45678`) are not matched against registered loopback URIs on any port and are rejected by dynamic client registration.
- WebDAV: `PROPFIND` `allprop` returns `DAV:current-user-principal`, so Windows Explorer shows every file and folder under the account name.
## [0.16.26] - 2026-10-11
If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions.
@@ -11,6 +27,7 @@ If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If
- JMAP: `Mailbox/set` allows moving a mailbox under a parent that already has a child with the same name, and rejects renames that only change the case of a mailbox name with `alreadyExists`.
## Changed
- Breaking: x86_64 binaries and Docker images (except musl builds) now require a CPU with the x86-64-v2 instruction set (which includes SSE3, SSSE3, SSE4.1, SSE4.2 and POPCNT), and the server refuses to start on older CPUs. Virtual machines using a generic CPU model such as QEMU's "Common KVM processor" are affected. To fix it, set the VM CPU type to `host` or `x86-64-v2-AES` (Proxmox), or switch to the musl build: the `-alpine` Docker tags (for example `stalwartlabs/stalwart:v0.16-alpine`) or the `x86_64-unknown-linux-musl` binary.
## Fixed
- DNSSEC: Use `hickory-resolver` patched fork at https://github.com/stalwartlabs/hickory-dns.
Generated
+171 -147
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "common"
version = "0.16.26"
version = "0.16.27"
edition = "2024"
build = "build.rs"
@@ -20,7 +20,7 @@ mail-parser = { version = "0.11", features = ["full_encoding"] }
mail-builder = { version = "1.0" }
mail-auth = { version = "0.13", features = ["generate", "arc"] }
smtp-proto = { version = "0.2.3", features = ["rkyv"] }
dns-update = { version = "0.5" }
dns-update = { version = "0.6" }
calcard = { version = "0.3", features = ["rkyv"] }
ahash = { version = "0.8.12", features = ["serde"] }
parking_lot = "0.12.5"
+69 -9
View File
@@ -212,15 +212,23 @@ pub fn loopback_redirect_parts(uri: &str) -> Option<(&str, &str)> {
for host in ["127.0.0.1", "[::1]"] {
if let Some(rest) = uri.strip_prefix(host) {
if let Some(path) = rest.strip_prefix('/') {
return Some((host, path));
} else if let Some(after_colon) = rest.strip_prefix(':')
&& let Some((port, path)) = after_colon.split_once('/')
&& !port.is_empty()
&& port.bytes().all(|b| b.is_ascii_digit())
{
return Some((host, path));
}
let rest = match rest.strip_prefix(':') {
Some(after_colon) => {
let rest = after_colon.trim_start_matches(|c: char| c.is_ascii_digit());
if rest.len() == after_colon.len() {
return None;
}
rest
}
None => rest,
};
return if let Some(path) = rest.strip_prefix('/') {
Some((host, path))
} else if rest.is_empty() || rest.starts_with('?') {
Some((host, rest))
} else {
None
};
}
}
None
@@ -293,3 +301,55 @@ pub fn validate_grant_metadata(
Ok(())
}
#[cfg(test)]
mod tests {
use super::{loopback_redirect_parts, redirect_uri_matches};
#[test]
fn loopback_matches_any_port() {
// Loopback redirects match regardless of port and of an empty versus "/" path (RFC 8252 §7.3)
for (registered, presented) in [
("http://127.0.0.1", "http://127.0.0.1:45678"),
("http://127.0.0.1/", "http://127.0.0.1:45678"),
("http://127.0.0.1", "http://127.0.0.1:45678/"),
("http://127.0.0.1/cb", "http://127.0.0.1:45678/cb"),
("http://127.0.0.1/?a=1", "http://127.0.0.1:45678?a=1"),
("http://[::1]", "http://[::1]:8080"),
("http://[::1]/", "http://[::1]:8080/"),
] {
assert!(
redirect_uri_matches(registered, presented),
"{registered} should match {presented}"
);
}
// Different paths, hosts or schemes never match
for (registered, presented) in [
("http://127.0.0.1/cb", "http://127.0.0.1:45678"),
("http://127.0.0.1", "http://127.0.0.1:45678/cb"),
("http://127.0.0.1", "http://[::1]:45678"),
("http://127.0.0.1", "https://127.0.0.1:45678"),
("http://127.0.0.1", "http://127.0.0.1.evil.com"),
("http://127.0.0.1", "http://127.0.0.1:[email protected]"),
("http://127.0.0.1", "http://127.0.0.1:45678#frag"),
] {
assert!(
!redirect_uri_matches(registered, presented),
"{registered} should not match {presented}"
);
}
// Malformed loopback URIs are not recognised
for uri in [
"http://127.0.0.1:",
"http://127.0.0.1:/cb",
"http://127.0.0.1:abc/",
"http://127.0.0.1.evil.com/",
"http://[email protected]/",
"http://localhost:8080/",
] {
assert_eq!(loopback_redirect_parts(uri), None, "{uri}");
}
}
}
+69 -3
View File
@@ -6,13 +6,19 @@
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use crate::{Server, manager::fetch_resource};
use crate::{
Server,
manager::{
defaults::{WEBUI_LATEST_URL, WEBUI_MAX_MAJOR_VERSION, WEBUI_PINNED_URL},
fetch_resource,
},
};
use ahash::AHashMap;
use arc_swap::ArcSwap;
use registry::schema::{enums::CompressionAlgo, structs::Application};
use std::{
borrow::Cow,
io::{self, Cursor, Read},
io::{self, Cursor, Read, Seek},
path::{Path, PathBuf},
sync::{
Arc,
@@ -199,7 +205,11 @@ impl WebApplicationManager {
Self {
base_path,
blob_key: BlobHash::generate(format!("{}{}", APP_BLOB_PREFIX, app.id.id()).as_bytes()),
url: app.object.resource_url,
url: if app.object.resource_url == WEBUI_LATEST_URL {
WEBUI_PINNED_URL.to_string()
} else {
app.object.resource_url
},
description: app.object.description,
expiry: app.object.auto_update_frequency.as_secs(),
oauth_client_id: app.object.oauth_client_id,
@@ -263,6 +273,15 @@ impl WebApplicationManager {
.ctx(Key::Url, url.clone())
.details("Failed to decompress application bundle")
})?;
if let Some(version) = unsupported_bundle_version(&mut archive) {
return Err(trc::ResourceEvent::Error
.caused_by(trc::location!())
.ctx(Key::Url, url.clone())
.details(format!(
"Application bundle version {version} is for a newer server, set the resource URL to {WEBUI_PINNED_URL}"
)));
}
let mut resources = AHashMap::with_capacity(archive.len());
for i in 0..archive.len() {
let mut file = archive.by_index(i).map_err(|err| {
@@ -498,6 +517,19 @@ fn rewrite_index(html: &str, prefix: &str, oauth_client_id_meta: Option<&str>) -
out.into_bytes()
}
fn unsupported_bundle_version<R: Read + Seek>(archive: &mut zip::ZipArchive<R>) -> Option<String> {
let file = archive.by_name("VERSION").ok()?;
let mut version = String::new();
let is_supported = file.take(32).read_to_string(&mut version).is_ok()
&& version
.trim()
.split('.')
.next()
.and_then(|major| major.parse::<u32>().ok())
.is_some_and(|major| major <= WEBUI_MAX_MAJOR_VERSION);
(!is_supported).then(|| version.trim().to_string())
}
fn oauth_client_id_meta(client_id: &str) -> String {
let mut meta = String::with_capacity(OAUTH_CLIENT_ID.len() + client_id.len());
meta.push_str("<meta name=\"oauth-client-id\" content=\"");
@@ -752,4 +784,38 @@ mod tests {
assert_ne!(apps.next_generation(), apps.next_generation());
}
fn bundle(version: Option<&str>) -> zip::ZipArchive<Cursor<Vec<u8>>> {
let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default();
writer.start_file("index.html", options).unwrap();
io::Write::write_all(&mut writer, INDEX.as_bytes()).unwrap();
if let Some(version) = version {
writer.start_file("VERSION", options).unwrap();
io::Write::write_all(&mut writer, version.as_bytes()).unwrap();
}
zip::ZipArchive::new(writer.finish().unwrap()).unwrap()
}
#[test]
fn bundles_newer_than_the_supported_major_version_are_rejected() {
// Bundles without VERSION or with a 1.x VERSION are accepted, anything else is rejected
assert_eq!(unsupported_bundle_version(&mut bundle(None)), None);
assert_eq!(
unsupported_bundle_version(&mut bundle(Some("1.0.12\n"))),
None
);
assert_eq!(
unsupported_bundle_version(&mut bundle(Some("2.0.0\n"))),
Some("2.0.0".to_string())
);
assert_eq!(
unsupported_bundle_version(&mut bundle(Some("10.1.0"))),
Some("10.1.0".to_string())
);
assert_eq!(
unsupported_bundle_version(&mut bundle(Some("garbage"))),
Some("garbage".to_string())
);
}
}
+6
View File
@@ -28,6 +28,12 @@ use store::{
},
};
pub const WEBUI_LATEST_URL: &str =
"https://github.com/stalwartlabs/webui/releases/latest/download/webui.zip";
pub const WEBUI_PINNED_URL: &str =
"https://github.com/stalwartlabs/webui/releases/download/v1.0.11/webui.zip";
pub const WEBUI_MAX_MAJOR_VERSION: u32 = 1;
pub const ASN_IPV4: &str =
"https://github.com/sapics/ip-location-db/releases/download/latest/origin-asn-ipv4.csv";
pub const ASN_IPV6: &str =
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "coordinator"
version = "0.16.26"
version = "0.16.27"
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "dav-proto"
version = "0.16.26"
version = "0.16.27"
edition = "2024"
[dependencies]
+1 -3
View File
@@ -366,7 +366,7 @@ impl Rfc1123DateTime {
}
impl DavProperty {
pub const ALL_PROPS: [DavProperty; 11] = [
pub const ALL_PROPS: [DavProperty; 10] = [
DavProperty::WebDav(WebDavProperty::CreationDate),
DavProperty::WebDav(WebDavProperty::DisplayName),
DavProperty::WebDav(WebDavProperty::GetETag),
@@ -374,7 +374,6 @@ impl DavProperty {
DavProperty::WebDav(WebDavProperty::ResourceType),
DavProperty::WebDav(WebDavProperty::LockDiscovery),
DavProperty::WebDav(WebDavProperty::SupportedLock),
DavProperty::WebDav(WebDavProperty::CurrentUserPrincipal),
DavProperty::WebDav(WebDavProperty::GetContentLanguage),
DavProperty::WebDav(WebDavProperty::GetContentLength),
DavProperty::WebDav(WebDavProperty::GetContentType),
@@ -390,7 +389,6 @@ impl DavProperty {
| DavProperty::WebDav(WebDavProperty::ResourceType)
| DavProperty::WebDav(WebDavProperty::LockDiscovery)
| DavProperty::WebDav(WebDavProperty::SupportedLock)
| DavProperty::WebDav(WebDavProperty::CurrentUserPrincipal)
| DavProperty::WebDav(WebDavProperty::GetContentLanguage)
| DavProperty::WebDav(WebDavProperty::GetContentLength)
| DavProperty::WebDav(WebDavProperty::GetContentType)
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "dav"
version = "0.16.26"
version = "0.16.27"
edition = "2024"
[dependencies]
+17 -10
View File
@@ -57,7 +57,7 @@ use groupware::{
use http_proto::HttpResponse;
use hyper::StatusCode;
use registry::schema::{enums::Permission, prelude::ObjectType};
use std::sync::Arc;
use std::{borrow::Cow, sync::Arc};
use store::{
ValueKey,
registry::RegistryQuery,
@@ -1725,13 +1725,20 @@ async fn add_base_collection_response(
));
return Ok(());
}
PropFind::AllProp(_) => [
DavProperty::WebDav(WebDavProperty::ResourceType),
DavProperty::WebDav(WebDavProperty::CurrentUserPrincipal),
DavProperty::WebDav(WebDavProperty::SupportedReportSet),
]
.as_slice(),
PropFind::Prop(items) => items,
PropFind::AllProp(include) => {
let mut props = Vec::with_capacity(include.len() + 2);
props.extend([
DavProperty::WebDav(WebDavProperty::ResourceType),
DavProperty::WebDav(WebDavProperty::SupportedReportSet),
]);
for prop in include {
if !props.contains(prop) {
props.push(prop.clone());
}
}
Cow::Owned(props)
}
PropFind::Prop(items) => Cow::Borrowed(items.as_slice()),
};
let mut fields = Vec::with_capacity(properties.len());
@@ -1741,9 +1748,9 @@ async fn add_base_collection_response(
.await
.caused_by(trc::location!())?;
for prop in properties {
for prop in properties.iter() {
response.set_namespace(prop.namespace());
match &prop {
match prop {
DavProperty::WebDav(WebDavProperty::ResourceType) => {
fields.push(DavPropertyValue::new(
prop.clone(),
+14 -8
View File
@@ -448,17 +448,23 @@ fn all_props(collection: Collection, all_props: Option<&[DavProperty]>) -> Vec<D
DavProperty::WebDav(WebDavProperty::DisplayName),
DavProperty::WebDav(WebDavProperty::ResourceType),
DavProperty::WebDav(WebDavProperty::SupportedReportSet),
DavProperty::WebDav(WebDavProperty::CurrentUserPrincipal),
DavProperty::WebDav(WebDavProperty::SyncToken),
DavProperty::WebDav(WebDavProperty::Owner),
DavProperty::WebDav(WebDavProperty::PrincipalCollectionSet),
];
if let Some(all_props) = all_props {
props.extend(all_props.iter().filter(|p| !p.is_all_prop()).cloned());
props
if let Some(include) = all_props {
for prop in include {
if !prop.is_all_prop() && !props.contains(prop) {
props.push(prop.clone());
}
}
} else {
props
props.extend([
DavProperty::WebDav(WebDavProperty::CurrentUserPrincipal),
DavProperty::WebDav(WebDavProperty::SyncToken),
DavProperty::WebDav(WebDavProperty::Owner),
DavProperty::WebDav(WebDavProperty::PrincipalCollectionSet),
]);
}
props
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "directory"
version = "0.16.26"
version = "0.16.27"
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "email"
version = "0.16.26"
version = "0.16.27"
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "groupware"
version = "0.16.26"
version = "0.16.27"
edition = "2024"
[dependencies]
+1 -1
View File
@@ -761,7 +761,7 @@ async fn commit_itip_merge(
.caused_by(trc::location!())?;
// Merge changes
itip_merge_changes(&mut event.data.event, changes);
itip_merge_changes(&mut event.data.event, &itip, changes);
// Calculate the new ical size
event.size = ical_size(&event.data.event) as u32;
+80 -1
View File
@@ -542,7 +542,7 @@ fn handle_reply(
Ok(())
}
pub fn itip_merge_changes(ical: &mut ICalendar, changes: Vec<MergeAction>) {
pub fn itip_merge_changes(ical: &mut ICalendar, itip: &ICalendar, changes: Vec<MergeAction>) {
let mut remove_component_ids: Vec<u32> = Vec::new();
for action in changes {
match action {
@@ -600,6 +600,85 @@ pub fn itip_merge_changes(ical: &mut ICalendar, changes: Vec<MergeAction>) {
if !remove_component_ids.is_empty() {
ical.remove_component_ids(&remove_component_ids);
}
merge_timezones(ical, itip);
}
fn merge_timezones(ical: &mut ICalendar, itip: &ICalendar) {
let Some(root) = ical
.components
.first()
.filter(|root| root.component_type == ICalendarComponentType::VCalendar)
else {
return;
};
let mut insert_at = root
.component_ids
.iter()
.position(|component_id| {
ical.component_by_id(*component_id)
.is_none_or(|comp| comp.component_type != ICalendarComponentType::VTimezone)
})
.unwrap_or(root.component_ids.len());
for tz in itip.timezones() {
let Some(tz_id) = timezone_id(tz) else {
continue;
};
let mut is_referenced = false;
for component in &ical.components {
if component.component_type == ICalendarComponentType::VTimezone {
if timezone_id(component) == Some(tz_id) {
is_referenced = false;
break;
}
} else if !is_referenced {
is_referenced = component
.entries
.iter()
.any(|entry| entry.tz_id() == Some(tz_id));
}
}
if !is_referenced {
continue;
}
let tz_component_id = ical.components.len() as u32;
ical.components.reserve(tz.component_ids.len() + 1);
ical.components.push(ICalendarComponent {
component_type: ICalendarComponentType::VTimezone,
entries: tz.entries.clone(),
component_ids: Vec::new(),
});
ical.components.extend(
tz.component_ids
.iter()
.filter_map(|component_id| itip.component_by_id(*component_id))
.map(|item| ICalendarComponent {
component_type: item.component_type.clone(),
entries: item.entries.clone(),
component_ids: Vec::new(),
}),
);
let last_item_id = ical.components.len() as u32;
if let Some(tz_component) = ical.components.get_mut(tz_component_id as usize) {
tz_component.component_ids = (tz_component_id + 1..last_item_id).collect();
}
if let Some(root) = ical.components.first_mut() {
root.component_ids.insert(insert_at, tz_component_id);
insert_at += 1;
}
}
ical.add_missing_timezones();
}
fn timezone_id(component: &ICalendarComponent) -> Option<&str> {
component
.property(&ICalendarProperty::Tzid)
.and_then(|entry| entry.values.first())
.and_then(|value| value.as_text())
}
pub fn itip_method(ical: &ICalendar) -> Result<&ICalendarMethod, ItipError> {
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "http_proto"
version = "0.16.26"
version = "0.16.27"
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "http"
version = "0.16.26"
version = "0.16.27"
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "imap_proto"
version = "0.16.26"
version = "0.16.27"
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "imap"
version = "0.16.26"
version = "0.16.27"
edition = "2024"
[dependencies]
+1
View File
@@ -169,6 +169,7 @@ impl<T: SessionStream> SessionData<T> {
.broadcast_push_notification(PushNotification::StateChange(
StateChange::new(account_id)
.with_change_id(change_id)
.with_change(DataType::EmailDelivery)
.with_change(DataType::Email)
.with_change(DataType::Mailbox)
.with_change(DataType::Thread),
+1
View File
@@ -648,6 +648,7 @@ impl<T: SessionStream> SessionData<T> {
.broadcast_push_notification(PushNotification::StateChange(
StateChange::new(dest_account_id)
.with_change_id(change_id)
.with_change(DataType::EmailDelivery)
.with_change(DataType::Email)
.with_change(DataType::Thread)
.with_change(DataType::Mailbox),
+25 -25
View File
@@ -175,9 +175,14 @@ impl<T: SessionStream> SessionData<T> {
None
};
let mut imap_ids = Vec::with_capacity(results_len);
let return_all = arguments.result_options.is_empty()
|| arguments.result_options.contains(&ResultOption::All);
mailbox.map_search_results(
result_set.into_iter(),
is_uid,
return_all
|| (saved_results.is_some()
&& arguments.result_options.contains(&ResultOption::Count)),
arguments.result_options.contains(&ResultOption::Min),
arguments.result_options.contains(&ResultOption::Max),
&mut min,
@@ -247,13 +252,7 @@ impl<T: SessionStream> SessionData<T> {
} else {
None
},
ids: if arguments.result_options.is_empty()
|| arguments.result_options.contains(&ResultOption::All)
{
imap_ids
} else {
vec![]
},
ids: if return_all { imap_ids } else { vec![] },
is_sort,
is_esearch: arguments.is_esearch,
highest_modseq,
@@ -747,6 +746,7 @@ impl SelectedMailbox {
&self,
ids: impl Iterator<Item = u32>,
is_uid: bool,
collect_all: bool,
find_min: bool,
find_max: bool,
min: &mut Option<(u32, ImapId)>,
@@ -759,26 +759,25 @@ impl SelectedMailbox {
let find_min_or_max = find_min || find_max;
for document_id in ids {
if let Some((id, imap_id)) = state.map_result_id(document_id, is_uid) {
if find_min_or_max {
if find_min {
if let Some((prev_min, _)) = min {
if id < *prev_min {
*min = Some((id, imap_id));
}
} else {
if find_min {
if let Some((prev_min, _)) = min {
if id < *prev_min {
*min = Some((id, imap_id));
}
} else {
*min = Some((id, imap_id));
}
if find_max {
if let Some((prev_max, _)) = max {
if id > *prev_max {
*max = Some((id, imap_id));
}
} else {
}
if find_max {
if let Some((prev_max, _)) = max {
if id > *prev_max {
*max = Some((id, imap_id));
}
} else {
*max = Some((id, imap_id));
}
} else {
}
if collect_all || !find_min_or_max {
imap_ids.push(id);
if let Some(r) = saved_results.as_mut() {
r.push(imap_id)
@@ -787,11 +786,12 @@ impl SelectedMailbox {
*total += 1;
}
}
if find_min || find_max {
for (id, imap_id) in [min, max].into_iter().flatten() {
imap_ids.push(*id);
if find_min_or_max && !collect_all {
let max = max.filter(|(max_id, _)| min.is_none_or(|(min_id, _)| min_id != *max_id));
for (id, imap_id) in [*min, max].into_iter().flatten() {
imap_ids.push(id);
if let Some(r) = saved_results.as_mut() {
r.push(*imap_id)
r.push(imap_id)
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "jmap_proto"
version = "0.16.26"
version = "0.16.27"
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "jmap"
version = "0.16.26"
version = "0.16.27"
edition = "2024"
[dependencies]
+17 -3
View File
@@ -10,7 +10,7 @@ use crate::{
changes::state::JmapCacheState,
email::{PatchResult, handle_email_patch, ingested_into_object},
};
use common::{Server, auth::AccessToken};
use common::{Server, auth::AccessToken, ipc::PushNotification};
use email::{
cache::{MessageCacheFetch, email::MessageCacheAccess, mailbox::MailboxCacheAccess},
mailbox::JUNK_ID,
@@ -37,7 +37,10 @@ use jmap_tools::{Key, Value};
use std::future::Future;
use store::write::BatchBuilder;
use trc::AddContext;
use types::acl::Acl;
use types::{
acl::Acl,
type_state::{DataType, StateChange},
};
use utils::map::vec_map::VecMap;
pub trait JmapEmailCopy: Sync + Send {
@@ -96,6 +99,7 @@ impl JmapEmailCopy for Server {
let mut destroy_ids = Vec::new();
let mut train_batch = BatchBuilder::new();
let mut did_train = false;
let mut last_change_id = None;
train_batch.with_account_id(from_account_id);
'create: for (id, create) in request.create.into_valid() {
@@ -232,6 +236,7 @@ impl JmapEmailCopy for Server {
.await?
{
Ok(email) => {
last_change_id = Some(email.change_id);
if train_spam {
self.add_account_spam_sample(
&mut train_batch,
@@ -277,7 +282,16 @@ impl JmapEmailCopy for Server {
}
// Update state
if !response.created.is_empty() {
if let Some(change_id) = last_change_id {
self.broadcast_push_notification(PushNotification::StateChange(
StateChange::new(account_id)
.with_change_id(change_id)
.with_change(DataType::EmailDelivery)
.with_change(DataType::Email)
.with_change(DataType::Mailbox)
.with_change(DataType::Thread),
))
.await;
response.new_state = self.get_cached_messages(account_id).await?.get_state(false);
}
+1
View File
@@ -223,6 +223,7 @@ impl EmailImport for Server {
self.broadcast_push_notification(PushNotification::StateChange(
StateChange::new(account_id)
.with_change_id(change_id)
.with_change(DataType::EmailDelivery)
.with_change(DataType::Email)
.with_change(DataType::Mailbox)
.with_change(DataType::Thread),
+12 -3
View File
@@ -1233,12 +1233,21 @@ impl EmailSet for Server {
if let Some(change_id) = last_change_id {
if response.updated.is_empty() && response.destroyed.is_empty() {
// Message ingest does not broadcast state changes
let mut state_change = StateChange::new(account_id)
.with_change_id(change_id)
.with_change(DataType::Email)
.with_change(DataType::Mailbox)
.with_change(DataType::Thread);
if !response.created.is_empty() {
state_change.set_change(DataType::EmailDelivery);
}
self.broadcast_push_notification(PushNotification::StateChange(state_change))
.await;
} else if !response.created.is_empty() {
self.broadcast_push_notification(PushNotification::StateChange(
StateChange::new(account_id)
.with_change_id(change_id)
.with_change(DataType::Email)
.with_change(DataType::Mailbox)
.with_change(DataType::Thread),
.with_change(DataType::EmailDelivery),
))
.await;
}
+1 -1
View File
@@ -7,7 +7,7 @@ keywords = ["imap", "jmap", "smtp", "email", "mail", "webdav", "server"]
categories = ["email"]
# Upstream offers AGPL-3.0-only OR LicenseRef-SEL; inbuxa takes the AGPL only.
license = "AGPL-3.0-only"
version = "0.16.26"
version = "0.16.27"
edition = "2024"
[[bin]]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "managesieve"
version = "0.16.26"
version = "0.16.27"
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "migration"
version = "0.16.26"
version = "0.16.27"
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "nlp"
version = "0.16.26"
version = "0.16.27"
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "pop3"
version = "0.16.26"
version = "0.16.27"
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "registry"
version = "0.16.26"
version = "0.16.27"
edition = "2024"
[dependencies]
+17
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
pub use crate::jmap::IntoValue;
pub use crate::jmap::JmapValue;
@@ -31,6 +33,7 @@ pub use crate::types::string::StringValidator;
pub use serde::{Deserialize, Serialize};
pub use std::borrow::Cow;
pub use std::str::FromStr;
use std::sync::LazyLock;
pub use types::blob::BlobId;
pub use types::id::Id;
pub use utils::map::vec_map::VecMap;
@@ -56,3 +59,17 @@ pub const OBJ_FILTER_ACCOUNT: u64 = 1 << 2;
pub const OBJ_FILTER_TENANT: u64 = 1 << 3;
pub const MASKED_PASSWORD: &str = "****";
// Temporary migration aid, will be removed by v1.
pub static INCLUDE_SECRETS: LazyLock<bool> = LazyLock::new(|| {
std::env::var("INBUXA_INCLUDE_SECRETS")
.is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
});
pub fn secret_value(value: impl IntoValue) -> JmapValue<'static> {
if *INCLUDE_SECRETS {
value.into_value()
} else {
JmapValue::Str(MASKED_PASSWORD.into())
}
}
+13 -16
View File
@@ -169,13 +169,10 @@ impl IntoValue for AccountPassword {
fn into_value(self) -> JmapValue<'static> {
let mut map = jmap_tools::Map::with_capacity(5);
if self.secret.is_some() {
map.insert_unchecked(Property::Secret, JmapValue::Str(MASKED_PASSWORD.into()));
map.insert_unchecked(Property::Secret, secret_value(self.secret));
}
if self.current_secret.is_some() {
map.insert_unchecked(
Property::CurrentSecret,
JmapValue::Str(MASKED_PASSWORD.into()),
);
map.insert_unchecked(Property::CurrentSecret, secret_value(self.current_secret));
}
map.insert_unchecked(Property::OtpAuth, self.otp_auth.into_value());
JmapValue::Object(map)
@@ -399,7 +396,7 @@ impl IntoValue for AcmeProvider {
map.insert_unchecked(Property::ChallengeType, self.challenge_type.into_value());
map.insert_unchecked(Property::Contact, self.contact.into_value());
map.insert_unchecked(Property::Directory, self.directory.into_value());
map.insert_unchecked(Property::AccountKey, JmapValue::Str(MASKED_PASSWORD.into()));
map.insert_unchecked(Property::AccountKey, secret_value(self.account_key));
map.insert_unchecked(Property::AccountUri, self.account_uri.into_value());
map.insert_unchecked(Property::RenewBefore, self.renew_before.into_value());
map.insert_unchecked(Property::MaxRetries, self.max_retries.into_value());
@@ -1525,7 +1522,7 @@ impl IntoValue for ApiKey {
fn into_value(self) -> JmapValue<'static> {
let mut map = jmap_tools::Map::with_capacity(8);
map.insert_unchecked(Property::Description, self.description.into_value());
map.insert_unchecked(Property::Secret, JmapValue::Str(MASKED_PASSWORD.into()));
map.insert_unchecked(Property::Secret, secret_value(self.secret));
map.insert_unchecked(Property::CreatedAt, self.created_at.into_value());
map.insert_unchecked(Property::ExpiresAt, self.expires_at.into_value());
map.insert_unchecked(Property::Permissions, self.permissions.into_value());
@@ -1633,7 +1630,7 @@ impl IntoValue for AppPassword {
fn into_value(self) -> JmapValue<'static> {
let mut map = jmap_tools::Map::with_capacity(8);
map.insert_unchecked(Property::Description, self.description.into_value());
map.insert_unchecked(Property::Secret, JmapValue::Str(MASKED_PASSWORD.into()));
map.insert_unchecked(Property::Secret, secret_value(self.secret));
map.insert_unchecked(Property::CreatedAt, self.created_at.into_value());
map.insert_unchecked(Property::ExpiresAt, self.expires_at.into_value());
map.insert_unchecked(Property::Permissions, self.permissions.into_value());
@@ -30554,7 +30551,7 @@ impl IntoValue for OAuthClient {
map.insert_unchecked(Property::Description, self.description.into_value());
map.insert_unchecked(Property::Contacts, self.contacts.into_value());
if self.secret.is_some() {
map.insert_unchecked(Property::Secret, JmapValue::Str(MASKED_PASSWORD.into()));
map.insert_unchecked(Property::Secret, secret_value(self.secret));
}
map.insert_unchecked(Property::CreatedAt, self.created_at.into_value());
map.insert_unchecked(Property::ExpiresAt, self.expires_at.into_value());
@@ -30956,10 +30953,10 @@ impl IntoValue for OtpAuth {
fn into_value(self) -> JmapValue<'static> {
let mut map = jmap_tools::Map::with_capacity(4);
if self.otp_code.is_some() {
map.insert_unchecked(Property::OtpCode, JmapValue::Str(MASKED_PASSWORD.into()));
map.insert_unchecked(Property::OtpCode, secret_value(self.otp_code));
}
if self.otp_url.is_some() {
map.insert_unchecked(Property::OtpUrl, JmapValue::Str(MASKED_PASSWORD.into()));
map.insert_unchecked(Property::OtpUrl, secret_value(self.otp_url));
}
JmapValue::Object(map)
}
@@ -31050,9 +31047,9 @@ impl IntoValue for PasswordCredential {
fn into_value(self) -> JmapValue<'static> {
let mut map = jmap_tools::Map::with_capacity(7);
map.insert_unchecked(Property::CredentialId, self.credential_id.into_value());
map.insert_unchecked(Property::Secret, JmapValue::Str(MASKED_PASSWORD.into()));
map.insert_unchecked(Property::Secret, secret_value(self.secret));
if self.otp_auth.is_some() {
map.insert_unchecked(Property::OtpAuth, JmapValue::Str(MASKED_PASSWORD.into()));
map.insert_unchecked(Property::OtpAuth, secret_value(self.otp_auth));
}
map.insert_unchecked(Property::ExpiresAt, self.expires_at.into_value());
map.insert_unchecked(Property::AllowedIps, self.allowed_ips.into_value());
@@ -34815,7 +34812,7 @@ impl IntoValue for SecondaryCredential {
let mut map = jmap_tools::Map::with_capacity(9);
map.insert_unchecked(Property::CredentialId, self.credential_id.into_value());
map.insert_unchecked(Property::Description, self.description.into_value());
map.insert_unchecked(Property::Secret, JmapValue::Str(MASKED_PASSWORD.into()));
map.insert_unchecked(Property::Secret, secret_value(self.secret));
map.insert_unchecked(Property::CreatedAt, self.created_at.into_value());
map.insert_unchecked(Property::ExpiresAt, self.expires_at.into_value());
map.insert_unchecked(Property::Permissions, self.permissions.into_value());
@@ -35218,7 +35215,7 @@ impl Default for SecretKeyValue {
impl IntoValue for SecretKeyValue {
fn into_value(self) -> JmapValue<'static> {
let mut map = jmap_tools::Map::with_capacity(3);
map.insert_unchecked(Property::Secret, JmapValue::Str(MASKED_PASSWORD.into()));
map.insert_unchecked(Property::Secret, secret_value(self.secret));
JmapValue::Object(map)
}
}
@@ -35503,7 +35500,7 @@ impl Default for SecretTextValue {
impl IntoValue for SecretTextValue {
fn into_value(self) -> JmapValue<'static> {
let mut map = jmap_tools::Map::with_capacity(3);
map.insert_unchecked(Property::Secret, JmapValue::Str(MASKED_PASSWORD.into()));
map.insert_unchecked(Property::Secret, secret_value(self.secret));
JmapValue::Object(map)
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "scim-proto"
version = "0.16.26"
version = "0.16.27"
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "scim"
version = "0.16.26"
version = "0.16.27"
edition = "2024"
[dependencies]
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "services"
version = "0.16.26"
version = "0.16.27"
edition = "2024"
[dependencies]
@@ -32,7 +32,7 @@ hkdf = "0.13"
sha2 = "0.11"
reqwest = { version = "0.13", default-features = false, features = ["rustls", "http2"]}
base64 = "0.23"
dns-update = { version = "0.5" }
dns-update = { version = "0.6" }
psl = "2"
# inbuxa: the deliverability check
mail-auth = { version = "0.13" }
+1 -1
View File
@@ -6,7 +6,7 @@ homepage = "https://inbuxa.org"
keywords = ["smtp", "email", "mail", "server"]
categories = ["email"]
license = "AGPL-3.0-only OR LicenseRef-SEL"
version = "0.16.26"
version = "0.16.27"
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "spam-filter"
version = "0.16.26"
version = "0.16.27"
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "store"
version = "0.16.26"
version = "0.16.27"
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "trc"
version = "0.16.26"
version = "0.16.27"
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "event_macro"
version = "0.16.26"
version = "0.16.27"
edition = "2024"
[lib]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "types"
version = "0.16.26"
version = "0.16.27"
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "utils"
version = "0.16.26"
version = "0.16.27"
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "proc_macros"
version = "0.16.26"
version = "0.16.27"
edition = "2024"
[lib]
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "tests"
version = "0.16.26"
version = "0.16.27"
edition = "2024"
[features]
@@ -82,7 +82,7 @@ form_urlencoded = "1.2.2"
rkyv = { version = "0.8.18", features = ["little_endian"] }
quick-xml = "0.41"
jmap-tools = { version = "0.1" }
dns-update = { version = "0.5", features = ["test_provider"] }
dns-update = { version = "0.6", features = ["test_provider"] }
x509-parser = "0.18"
rcgen = "0.14"
sha2 = "0.11"
+60 -1
View File
@@ -29,7 +29,7 @@ pub async fn test(imap: &mut ImapConnection, imap_check: &mut ImapConnection, te
imap.send("SEARCH RETURN (MIN MAX COUNT ALL) ALL").await;
imap.assert_read(Type::Tagged, ResponseType::Ok)
.await
.assert_contains("COUNT 10 MIN 1 MAX 10 ALL 1,10");
.assert_contains("COUNT 10 MIN 1 MAX 10 ALL 1:10");
imap_check.send("UID SEARCH ALL").await;
imap_check
.assert_read(Type::Tagged, ResponseType::Ok)
@@ -118,6 +118,65 @@ pub async fn test(imap: &mut ImapConnection, imap_check: &mut ImapConnection, te
.await
.assert_contains("MIN 2 MAX 9");
// Saved search with MIN/MAX and ALL saves every match
imap_check.send(
"UID SEARCH RETURN (SAVE MIN MAX ALL) OR OR FROM nathaniel FROM vandelay OR SUBJECT rfc FROM gore",
)
.await;
imap_check
.assert_read(Type::Tagged, ResponseType::Ok)
.await
.assert_contains("MIN 1 MAX 10 ALL 1,3:4,6,8,10");
imap_check.send("UID SEARCH $").await;
imap_check
.assert_read(Type::Tagged, ResponseType::Ok)
.await
.assert_equals("* SEARCH 1 3 4 6 8 10");
// Saved search with MIN and COUNT saves every match
imap_check.send(
"UID SEARCH RETURN (SAVE MIN COUNT) OR OR FROM nathaniel FROM vandelay OR SUBJECT rfc FROM gore",
)
.await;
imap_check
.assert_read(Type::Tagged, ResponseType::Ok)
.await
.assert_contains("COUNT 6 MIN 1");
imap_check.send("UID SEARCH $").await;
imap_check
.assert_read(Type::Tagged, ResponseType::Ok)
.await
.assert_equals("* SEARCH 1 3 4 6 8 10");
// Saved search with only MIN and MAX saves the extremes
imap_check.send(
"UID SEARCH RETURN (SAVE MIN MAX) OR OR FROM nathaniel FROM vandelay OR SUBJECT rfc FROM gore",
)
.await;
imap_check
.assert_read(Type::Tagged, ResponseType::Ok)
.await
.assert_contains("MIN 1 MAX 10");
imap_check.send("UID SEARCH $").await;
imap_check
.assert_read(Type::Tagged, ResponseType::Ok)
.await
.assert_equals("* SEARCH 1 10");
// Saved search with MIN and MAX on a single match saves it once
imap_check
.send("UID SEARCH RETURN (SAVE MIN MAX) UID 8")
.await;
imap_check
.assert_read(Type::Tagged, ResponseType::Ok)
.await
.assert_contains("MIN 8 MAX 8");
imap_check.send("UID SEARCH $").await;
imap_check
.assert_read(Type::Tagged, ResponseType::Ok)
.await
.assert_equals("* SEARCH 8");
// Sort
imap_check
.send("UID SORT (REVERSE SUBJECT REVERSE DATE) UTF-8 FROM Nathaniel")
+31
View File
@@ -97,6 +97,37 @@ pub async fn test(test: &TestServer) {
.await;
assert_ping(&mut event_rx).await;
// Import email and expect EmailDelivery state change
client
.email_import(
concat!(
"From: [email protected]\r\n",
"To: [email protected]\r\n",
"Subject: TPS Report (imported)\r\n",
"\r\n",
"Did you get the memo?"
)
.as_bytes()
.to_vec(),
[Id::from(INBOX_ID).to_string()],
None::<Vec<&str>>,
None,
)
.await
.unwrap();
assert_state(
&mut event_rx,
account.id_string(),
&[
DataType::EmailDelivery,
DataType::Email,
DataType::Thread,
DataType::Mailbox,
],
)
.await;
assert_ping(&mut event_rx).await;
// Destroy mailbox
client.mailbox_destroy(&mailbox_id, true).await.unwrap();
assert_state(&mut event_rx, account.id_string(), &[DataType::Mailbox]).await;
+3
View File
@@ -250,6 +250,9 @@ pub async fn test(test: &mut TestServer) {
"http://127.0.0.1/cb",
"http://127.0.0.1:54321/cb",
"http://[::1]:8080/cb",
"http://127.0.0.1",
"http://127.0.0.1:54321",
"http://[::1]:8080",
] {
let (status, body) = post_json_raw(
&metadata.registration_endpoint,
+3 -1
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use ahash::AHashMap;
@@ -277,7 +279,7 @@ pub fn test() {
) {
Ok(result) => match result {
MergeResult::Actions(changes) => {
itip_merge_changes(ical, changes);
itip_merge_changes(ical, &message.message, changes);
Ok(None)
}
MergeResult::Message(message) => {
+60
View File
@@ -170,6 +170,66 @@ pub async fn test(test: &TestServer, assisted_discovery: bool) {
.with_status(StatusCode::MULTI_STATUS)
.with_hrefs([test_path.as_str()]);
// Allprop must not return href-valued properties outside RFC 4918 unless included
let user_base_href = format!("{user_base_path}/");
let href_props = [
DavProperty::WebDav(WebDavProperty::CurrentUserPrincipal),
DavProperty::WebDav(WebDavProperty::Owner),
DavProperty::WebDav(WebDavProperty::PrincipalCollectionSet),
];
for (path, hrefs) in [
(
resource_type.base_path(),
[resource_type.collection_path(), user_base_href.as_str()],
),
(
user_base_path.as_str(),
[user_base_href.as_str(), test_base_path.as_str()],
),
(
test_base_path.as_str(),
[test_base_path.as_str(), test_path.as_str()],
),
] {
let response = client
.request_with_headers("PROPFIND", path, [("depth", "1")], "")
.await
.with_status(StatusCode::MULTI_STATUS)
.into_propfind_response(None);
for href in hrefs {
let properties = response.properties(href);
properties.is_defined(DavProperty::WebDav(WebDavProperty::ResourceType));
for prop in &href_props {
properties.is_undefined(prop);
}
}
let response = client
.request_with_headers(
"PROPFIND",
path,
[("depth", "1")],
concat!(
r#"<D:propfind xmlns:D="DAV:"><D:allprop/>"#,
r#"<D:include><D:current-user-principal/></D:include>"#,
r#"</D:propfind>"#
),
)
.await
.with_status(StatusCode::MULTI_STATUS)
.into_propfind_response(None);
for href in hrefs {
response
.properties(href)
.get(DavProperty::WebDav(WebDavProperty::CurrentUserPrincipal))
.with_values([format!(
"D:href:{}/jane%40example.com/",
DavResourceName::Principal.base_path()
)
.as_str()]);
}
}
// Test 8 PROPFIND with prefer return=minimal
let response = client
.propfind_with_headers(&test_base_path, ALL_DAV_PROPERTIES, [])
+7
View File
@@ -26,3 +26,10 @@ crates/types/src/branding.rs "STALWART"
# Upstream's old default rules source, read only to treat it as unset: the
# server uses the rules bundled with it (resources/spam-filter/).
crates/common/src/manager/spam_rules.rs "https://github.com/stalwartlabs/spam-filter/releases/latest/download/spam-filter-rules.json.gz"
# Upstream's web interface downloads, used only by an install upgraded from
# Stalwart that still has its web application: inbuxa installs none
# (docs/spec/SPEC.md §5.3), and the pin keeps an upgraded one on the 1.x
# bundles this server line can serve.
crates/common/src/manager/defaults.rs "https://github.com/stalwartlabs/webui/releases/download/v1.0.11/webui.zip"
crates/common/src/manager/defaults.rs "https://github.com/stalwartlabs/webui/releases/latest/download/webui.zip"
+1
View File
@@ -41,6 +41,7 @@ TEXT_RENAMES = [
('(vnd.stalwart)', '(vnd.inbuxa)', None),
('stalwart-webui', 'inbuxa-webui', None),
('STALWART_SPAM_', 'INBUXA_SPAM_', None),
('STALWART_INCLUDE_SECRETS', 'INBUXA_INCLUDE_SECRETS', ('crates',)),
# Configuration defaults, as upstream's generated registry code spells
# them. Server code only: the tests use the same spelling for fixtures
# that must match their containers and identity provider (database users,