Rename Stalwart's Sieve names for inbuxa, and fail when activation does
ci / test (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 2m11s
ci / announce (pull_request) Skipped

inbuxa accepts vnd.inbuxa.while and vnd.inbuxa.expressions, and names its
environment items vnd.inbuxa.*, with no alias for Stalwart's vnd.stalwart.*
names. A script carried over unchanged failed to compile on the target, and
the account was left with no filtering behind a single warning.

When the target lists vnd.inbuxa extensions in its sieveExtensions, export
now renames Stalwart's names in the three places they are names: the strings
of a require list, the item name of an environment test, and
${env.vnd.stalwart.*} references inside strings. A small tokenizer skips
comments and reads quoted strings and text: blocks whole, so other text that
happens to contain the name is copied unchanged. Each rename is printed.
Against a target without the inbuxa names nothing changes.

The activation call is now checked. A method error, or an active script
that could not be created, is logged as an error naming the script and
counted as a failure, so export exits non-zero instead of leaving
filtering off with one warning line.
This commit is contained in:
2026-09-30 11:24:16 -07:00
parent 2d94915702
commit 470fda6ac8
4 changed files with 653 additions and 8 deletions
+31
View File
@@ -1,5 +1,6 @@
/* /*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 John Coffey <[email protected]>
* *
* SPDX-License-Identifier: Apache-2.0 OR MIT * SPDX-License-Identifier: Apache-2.0 OR MIT
*/ */
@@ -97,6 +98,34 @@ impl<'a> Uploader<'a> {
Ok(id) Ok(id)
} }
/// As `upload_with`, but sends `bytes` in place of the stored blob: for
/// content rewritten on its way to the target. Cached under the same
/// local id, so a retry sends the rewritten bytes again.
fn upload_bytes_as(
&mut self,
local_id: i64,
content_type: &str,
bytes: &[u8],
) -> Result<JmapId, JmapError> {
self.touched.push(local_id);
if let Some(id) = self.cache.get(&local_id) {
return Ok(id.clone());
}
let id = if self.net.dry_run {
JmapId(format!("dryrun-blob-{local_id}"))
} else {
blobxfer::upload_bytes(
&self.net.client,
&self.net.session,
&self.net.account,
content_type,
bytes,
)?
};
self.cache.insert(local_id, id.clone());
Ok(id)
}
fn invalidate(&mut self, local_id: i64) { fn invalidate(&mut self, local_id: i64) {
self.cache.remove(&local_id); self.cache.remove(&local_id);
} }
@@ -435,6 +464,8 @@ mod keyed;
mod sieve; mod sieve;
mod sieve_names;
mod uidtype; mod uidtype;
mod email; mod email;
+81 -8
View File
@@ -1,5 +1,6 @@
/* /*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 John Coffey <[email protected]>
* *
* SPDX-License-Identifier: Apache-2.0 OR MIT * SPDX-License-Identifier: Apache-2.0 OR MIT
*/ */
@@ -9,10 +10,12 @@ use std::collections::{HashMap, HashSet};
use serde_json::{Value, json}; use serde_json::{Value, json};
use super::common::{create_batch, jid, retry_if_blob_missing, target_get_all}; use super::common::{create_batch, jid, retry_if_blob_missing, target_get_all};
use super::sieve_names;
use super::{Maps, Net, Plan, Uploader}; use super::{Maps, Net, Plan, Uploader};
use crate::error::Error; use crate::error::Error;
use crate::jmap::request::Request; use crate::jmap::request::{Request, check_method_error};
use crate::logging::Logger; use crate::logging::{LEVEL_DEFAULT, Logger};
use crate::sync::import_jmap::mapping::BlobBytes;
use crate::sync::import_jmap::mapping::{SIEVE_SELECT, row_to_sieve_script}; use crate::sync::import_jmap::mapping::{SIEVE_SELECT, row_to_sieve_script};
use crate::sync::{Context, TypeCounts}; use crate::sync::{Context, TypeCounts};
use crate::types::ObjectType; use crate::types::ObjectType;
@@ -57,6 +60,11 @@ pub fn reconcile(
let mut active_target: Option<String> = None; let mut active_target: Option<String> = None;
let mut deactivate = false; let mut deactivate = false;
let mut uploader = Uploader::new(net, &ctx.conn); let mut uploader = Uploader::new(net, &ctx.conn);
let rename_vendor = sieve_names::target_uses_inbuxa_names(&target_sieve_extensions(net));
let wanted_active = locals
.iter()
.find(|(_, _, a, _)| *a)
.map(|(_, n, _, _)| n.clone().unwrap_or_default());
for (local, name, is_active, blob_local) in &locals { for (local, name, is_active, blob_local) in &locals {
let matched = name.as_ref().and_then(|n| target_by_name.get(n)).cloned(); let matched = name.as_ref().and_then(|n| target_by_name.get(n)).cloned();
@@ -65,10 +73,18 @@ pub fn reconcile(
id id
} else { } else {
let cid = format!("c{local}"); let cid = format!("c{local}");
let label = name.as_deref().unwrap_or("(unnamed)");
let rewritten = if rename_vendor {
renamed_script(&uploader, *blob_local, label, logger)?
} else {
None
};
let build = |up: &mut Uploader<'_>| -> Result<Value, Error> { let build = |up: &mut Uploader<'_>| -> Result<Value, Error> {
let blob_id = up let blob_id = match &rewritten {
.upload_with(*blob_local, "application/sieve") Some(bytes) => up.upload_bytes_as(*blob_local, "application/sieve", bytes),
.map_err(Error::from)?; None => up.upload_with(*blob_local, "application/sieve"),
}
.map_err(Error::from)?;
let mut obj = serde_json::Map::new(); let mut obj = serde_json::Map::new();
if let Some(n) = name { if let Some(n) = name {
obj.insert("name".to_owned(), Value::String(n.clone())); obj.insert("name".to_owned(), Value::String(n.clone()));
@@ -92,7 +108,7 @@ pub fn reconcile(
} }
None => { None => {
for (cid, err) in &outcome.not_created { for (cid, err) in &outcome.not_created {
logger.warn(&format!("SieveScript {cid} not created: {err}")); logger.warn(&format!("SieveScript {label} ({cid}) not created: {err}"));
} }
counts.failed += 1; counts.failed += 1;
continue; continue;
@@ -107,6 +123,15 @@ pub fn reconcile(
if active_target.is_none() && locals.iter().all(|(_, _, a, _)| !*a) { if active_target.is_none() && locals.iter().all(|(_, _, a, _)| !*a) {
deactivate = true; deactivate = true;
} }
if let (Some(name), None) = (&wanted_active, &active_target) {
// The script that was active at the source never made it to the
// target (its creation failure is already counted): say plainly that
// the account now has no filtering, rather than leave it to a warning.
logger.error(&format!(
"the active Sieve script \"{name}\" could not be created on the target; \
no filtering is active there"
));
}
if !net.dry_run { if !net.dry_run {
let mut req = Request::new(); let mut req = Request::new();
@@ -118,8 +143,20 @@ pub fn reconcile(
json!({ "accountId": net.account }) json!({ "accountId": net.account })
}; };
req.call("SieveScript/set", args, "a"); req.call("SieveScript/set", args, "a");
if let Err(e) = req.send(&net.client, &net.api) { let result = req
logger.warn(&format!("SieveScript activation failed: {e}")); .send(&net.client, &net.api)
.and_then(|resp| resp.by_call_id("a").cloned())
.and_then(|mr| check_method_error(&mr));
if let Err(e) = result {
match (&active_target, &wanted_active) {
(Some(_), Some(name)) => {
logger.error(&format!(
"the Sieve script \"{name}\" was created but could not be activated: {e}"
));
counts.failed += 1;
}
_ => logger.warn(&format!("SieveScript activation failed: {e}")),
}
} }
} }
@@ -136,3 +173,39 @@ pub fn reconcile(
active_sieve_target: active_target, active_sieve_target: active_target,
}) })
} }
/// The target's `sieveExtensions`, from its Sieve account capability.
fn target_sieve_extensions(net: &Net) -> Vec<String> {
net.session
.account_capabilities(&net.account)
.and_then(|caps| caps.get("urn:ietf:params:jmap:sieve"))
.and_then(|c| c.get("sieveExtensions"))
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(Value::as_str)
.map(str::to_owned)
.collect()
})
.unwrap_or_default()
}
/// The script's bytes with Stalwart's vendor names renamed for an inbuxa
/// target, or `None` when it needs no change. Each rename is logged.
fn renamed_script(
uploader: &Uploader<'_>,
blob_local: i64,
label: &str,
logger: &Logger,
) -> Result<Option<Vec<u8>>, Error> {
let bytes = uploader.bytes(blob_local).map_err(Error::from)?;
Ok(sieve_names::rewrite(&bytes).map(|(out, renamed)| {
for old in &renamed {
let new = old.replacen("vnd.stalwart.", "vnd.inbuxa.", 1);
if logger.enabled(LEVEL_DEFAULT) {
eprintln!("export: SieveScript {label}: renamed {old} to {new}");
}
}
out
}))
}
+342
View File
@@ -0,0 +1,342 @@
/*
* SPDX-FileCopyrightText: 2026 John Coffey <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
//! Stalwart's vendor Sieve names, renamed for inbuxa.
//!
//! inbuxa accepts `vnd.inbuxa.while` and `vnd.inbuxa.expressions` where
//! Stalwart accepted `vnd.stalwart.*`, with no alias, and names its
//! environment items the same way. A script carried over unchanged fails to
//! compile on inbuxa, so export renames those names -- and only those names:
//! the strings of a `require` list, the name argument of an `environment`
//! test, and `${env.vnd.stalwart.…}` references inside strings. Everything
//! else in the script, including other strings that happen to contain the
//! text, is copied byte for byte.
const OLD: &str = "vnd.stalwart.";
const NEW: &str = "vnd.inbuxa.";
const OLD_ENV_REF: &str = "${env.vnd.stalwart.";
const NEW_ENV_REF: &str = "${env.vnd.inbuxa.";
/// Whether the target advertises inbuxa's vendor extensions, from the
/// `sieveExtensions` list of its `urn:ietf:params:jmap:sieve` account
/// capability.
pub fn target_uses_inbuxa_names(sieve_extensions: &[String]) -> bool {
sieve_extensions.iter().any(|e| e.starts_with(NEW))
}
/// The script with Stalwart's vendor names renamed, and the old names that
/// were changed, in order. `None` when nothing needed renaming, or when the
/// script is not UTF-8 (left alone rather than guessed at).
pub fn rewrite(script: &[u8]) -> Option<(Vec<u8>, Vec<String>)> {
let text = std::str::from_utf8(script).ok()?;
if !text.contains(OLD) {
return None;
}
let mut out = String::with_capacity(text.len());
let mut renamed = Vec::new();
let mut copied = 0;
let mut context = Context::None;
for tok in Tokens::new(text) {
match tok.kind {
Kind::Word => {
context = match tok.text(text).to_ascii_lowercase().as_str() {
"require" => Context::Require,
"environment" => Context::Environment,
_ if context == Context::Environment => Context::Environment,
_ => Context::None,
};
}
Kind::Tag => {
// `environment :comparator "i;octet"`: the comparator's own
// string is not the item name.
if context == Context::Environment
&& tok.text(text).eq_ignore_ascii_case(":comparator")
{
context = Context::EnvironmentComparator;
}
}
Kind::Quoted | Kind::Multiline => {
let (start, end) = tok.content;
let content = &text[start..end];
let mut replacement: Option<String> = None;
let whole_name = matches!(context, Context::Require | Context::Environment);
if whole_name && content.starts_with(OLD) {
replacement = Some(format!("{NEW}{}", &content[OLD.len()..]));
renamed.push(content.to_owned());
}
let current = replacement.as_deref().unwrap_or(content);
if current.contains(OLD_ENV_REF) {
let mut n = 0;
let mut rest = current;
while let Some(i) = rest.find(OLD_ENV_REF) {
let tail = &rest[i + 2..];
let name_end = tail.find('}').unwrap_or(tail.len());
renamed.push(tail[4..name_end].to_owned());
rest = &rest[i + OLD_ENV_REF.len()..];
n += 1;
}
if n > 0 {
replacement = Some(current.replace(OLD_ENV_REF, NEW_ENV_REF));
}
}
if let Some(r) = replacement {
out.push_str(&text[copied..start]);
out.push_str(&r);
copied = end;
}
context = match context {
Context::Require => Context::Require,
Context::EnvironmentComparator => Context::Environment,
_ => Context::None,
};
}
Kind::Punct(';') | Kind::Punct('{') | Kind::Punct('}') => context = Context::None,
Kind::Punct(_) => {}
}
}
if renamed.is_empty() {
return None;
}
out.push_str(&text[copied..]);
Some((out.into_bytes(), renamed))
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Context {
None,
Require,
Environment,
EnvironmentComparator,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Kind {
Word,
Tag,
Quoted,
Multiline,
Punct(char),
}
struct Token {
kind: Kind,
span: (usize, usize),
/// The string's content, without quotes or the `text:` framing.
content: (usize, usize),
}
impl Token {
fn text<'a>(&self, src: &'a str) -> &'a str {
&src[self.span.0..self.span.1]
}
}
/// Just enough of RFC 5228's lexer to find strings and the words before
/// them: comments are skipped, and quoted strings and `text:` blocks are
/// read whole, so nothing inside them is mistaken for a command.
struct Tokens<'a> {
src: &'a str,
pos: usize,
}
impl<'a> Tokens<'a> {
fn new(src: &'a str) -> Self {
Tokens { src, pos: 0 }
}
}
impl Iterator for Tokens<'_> {
type Item = Token;
fn next(&mut self) -> Option<Token> {
let b = self.src.as_bytes();
loop {
while self.pos < b.len() && b[self.pos].is_ascii_whitespace() {
self.pos += 1;
}
if self.pos >= b.len() {
return None;
}
if b[self.pos] == b'#' {
while self.pos < b.len() && b[self.pos] != b'\n' {
self.pos += 1;
}
continue;
}
if b[self.pos..].starts_with(b"/*") {
self.pos = match self.src[self.pos + 2..].find("*/") {
Some(i) => self.pos + 2 + i + 2,
None => b.len(),
};
continue;
}
break;
}
let start = self.pos;
let c = b[start];
if c == b'"' {
let mut i = start + 1;
while i < b.len() && b[i] != b'"' {
i += if b[i] == b'\\' { 2 } else { 1 };
}
let end = i.min(b.len());
self.pos = (end + 1).min(b.len());
return Some(Token {
kind: Kind::Quoted,
span: (start, self.pos),
content: (start + 1, end),
});
}
if c.is_ascii_alphabetic() || c == b'_' || c == b':' {
let mut i = start + 1;
while i < b.len() && (b[i].is_ascii_alphanumeric() || b[i] == b'_') {
i += 1;
}
let word = &self.src[start..i];
if word.eq_ignore_ascii_case("text:")
|| (word.eq_ignore_ascii_case("text") && b.get(i) == Some(&b':'))
{
let after = if b.get(i) == Some(&b':') { i + 1 } else { i };
// The body starts after the rest of the `text:` line and runs
// to a line holding a single dot.
let body = match self.src[after..].find('\n') {
Some(n) => after + n + 1,
None => b.len(),
};
let (body_end, next) = find_dot_line(self.src, body);
self.pos = next;
return Some(Token {
kind: Kind::Multiline,
span: (start, next),
content: (body, body_end),
});
}
self.pos = i;
let kind = if c == b':' { Kind::Tag } else { Kind::Word };
return Some(Token {
kind,
span: (start, i),
content: (start, i),
});
}
let ch = self.src[start..].chars().next().unwrap_or('\0');
self.pos = start + ch.len_utf8();
Some(Token {
kind: Kind::Punct(ch),
span: (start, self.pos),
content: (start, self.pos),
})
}
}
/// End of a `text:` body (the start of its closing dot line) and the offset
/// after that line.
fn find_dot_line(src: &str, from: usize) -> (usize, usize) {
let mut line_start = from;
while line_start < src.len() {
let line_end = src[line_start..]
.find('\n')
.map(|n| line_start + n)
.unwrap_or(src.len());
if src[line_start..line_end].trim_end_matches('\r') == "." {
return (line_start, (line_end + 1).min(src.len()));
}
line_start = line_end + 1;
}
(src.len(), src.len())
}
#[cfg(test)]
mod tests {
use super::*;
fn run(s: &str) -> Option<(String, Vec<String>)> {
rewrite(s.as_bytes()).map(|(b, r)| (String::from_utf8(b).unwrap(), r))
}
#[test]
fn renames_a_require_list() {
let (out, renamed) =
run("require [\"fileinto\", \"vnd.stalwart.while\", \"vnd.stalwart.expressions\"];\n")
.unwrap();
assert_eq!(
out,
"require [\"fileinto\", \"vnd.inbuxa.while\", \"vnd.inbuxa.expressions\"];\n"
);
assert_eq!(renamed, ["vnd.stalwart.while", "vnd.stalwart.expressions"]);
}
#[test]
fn renames_a_single_require_string() {
let (out, _) = run("REQUIRE \"vnd.stalwart.while\";").unwrap();
assert_eq!(out, "REQUIRE \"vnd.inbuxa.while\";");
}
#[test]
fn renames_the_environment_item_name_only() {
let src = "if environment :comparator \"i;octet\" :is \"vnd.stalwart.username\" \"vnd.stalwart.x\" { keep; }";
let (out, renamed) = run(src).unwrap();
assert_eq!(
out,
"if environment :comparator \"i;octet\" :is \"vnd.inbuxa.username\" \"vnd.stalwart.x\" { keep; }"
);
assert_eq!(renamed, ["vnd.stalwart.username"]);
}
#[test]
fn renames_env_references_inside_strings() {
let src = "set \"box\" \"${env.vnd.stalwart.default_mailbox}/Archive\";";
let (out, renamed) = run(src).unwrap();
assert_eq!(
out,
"set \"box\" \"${env.vnd.inbuxa.default_mailbox}/Archive\";"
);
assert_eq!(renamed, ["vnd.stalwart.default_mailbox"]);
}
#[test]
fn renames_env_references_in_text_blocks() {
let src = "vacation text:\nHi ${env.vnd.stalwart.username}.\n.\n;\n";
let (out, _) = run(src).unwrap();
assert_eq!(
out,
"vacation text:\nHi ${env.vnd.inbuxa.username}.\n.\n;\n"
);
}
#[test]
fn leaves_unrelated_strings_comments_and_text_alone() {
let src = "# vnd.stalwart.while is old\n/* \"vnd.stalwart.x\" */\n\
if header :contains \"subject\" \"vnd.stalwart.while\" { fileinto \"vnd.stalwart.box\"; }\n\
vacation text:\nrequire \"vnd.stalwart.while\";\n.\n;\n";
assert!(run(src).is_none());
}
#[test]
fn require_context_ends_at_the_semicolon() {
let src = "require \"fileinto\"; fileinto \"vnd.stalwart.folder\";";
assert!(run(src).is_none());
}
#[test]
fn nothing_to_do_is_none() {
assert!(run("require \"fileinto\";\nkeep;\n").is_none());
assert!(rewrite(&[0xff, 0xfe, b'v']).is_none());
}
#[test]
fn target_detection() {
assert!(target_uses_inbuxa_names(&[
"fileinto".to_owned(),
"vnd.inbuxa.while".to_owned()
]));
assert!(!target_uses_inbuxa_names(&[
"fileinto".to_owned(),
"vnd.stalwart.while".to_owned()
]));
assert!(!target_uses_inbuxa_names(&[]));
}
}
+199
View File
@@ -2683,6 +2683,205 @@ fn export_sieve_script_matches_by_name_not_content() {
let _ = std::fs::remove_file(&archive); let _ = std::fs::remove_file(&archive);
} }
/// A session like `session_body_full`, whose Sieve capability lists
/// `extensions` in `sieveExtensions`.
fn session_body_sieve(base: &str, extensions: &[&str]) -> String {
let mut v: serde_json::Value = serde_json::from_str(&session_body_full(base)).unwrap();
v["accounts"]["w"]["accountCapabilities"]["urn:ietf:params:jmap:sieve"] =
json!({ "sieveExtensions": extensions });
v.to_string()
}
/// One active script `name` in a fresh archive, with `body` as its content.
fn archive_with_active_sieve(name: &str, body: &[u8]) -> PathBuf {
let archive = tmp();
let conn = db::init::open(&archive).unwrap();
let blob = db::blobs::intern_blob(&conn, body).unwrap();
conn.execute(
"INSERT INTO sieve_scripts (id,name,is_active,blob_id) VALUES (1,?1,1,?2)",
rusqlite::params![name, blob],
)
.unwrap();
archive
}
/// Mocks for exporting one script to an empty target: get, upload (matched
/// by `upload_body`), create, and activation answered with `activation`.
/// Returns the upload mock and the activation mock.
fn mock_sieve_export(
server: &mut mockito::ServerGuard,
session: String,
upload_body: Matcher,
create_ok: bool,
activation: serde_json::Value,
) -> (mockito::Mock, mockito::Mock, Vec<mockito::Mock>) {
let api = "/jmap/api";
let mut keep = vec![
server.mock("GET", "/").with_status(404).create(),
server
.mock("GET", "/.well-known/jmap")
.with_body(session)
.expect_at_least(1)
.create(),
server
.mock("POST", api)
.match_body(Matcher::Regex("SieveScript/get".into()))
.with_body(
json!({"methodResponses":[["SieveScript/get",
{"accountId":"w","list":[],"notFound":[]},"g"]]})
.to_string(),
)
.create(),
];
let upload = server
.mock("POST", Matcher::Regex("/jmap/upload/".into()))
.match_body(upload_body)
.with_body(json!({"blobId":"UPN"}).to_string())
.expect(1)
.create();
let created = if create_ok {
json!({"accountId":"w","created":{"c1":{"id":"S1"}}})
} else {
json!({"accountId":"w","notCreated":{"c1":{"type":"invalidScript",
"description":"unknown extension"}}})
};
keep.push(
server
.mock("POST", api)
.match_body(Matcher::AllOf(vec![
Matcher::Regex("SieveScript/set".into()),
Matcher::Regex("\"create\"".into()),
]))
.with_body(json!({"methodResponses":[["SieveScript/set", created, "s"]]}).to_string())
.create(),
);
let activate = server
.mock("POST", api)
.match_body(Matcher::AllOf(vec![
Matcher::Regex("SieveScript/set".into()),
Matcher::Regex("onSuccess".into()),
]))
.with_body(json!({"methodResponses":[activation]}).to_string())
.expect(1)
.create();
(upload, activate, keep)
}
fn sieve_counts(summary: &sync::Summary) -> sync::TypeCounts {
summary
.per_type
.iter()
.find(|(t, _)| *t == "SieveScript")
.map(|(_, c)| c.clone())
.expect("sieve counts")
}
#[test]
fn export_sieve_renames_stalwart_names_for_an_inbuxa_target() {
let mut server = mockito::Server::new();
let base = server.url();
let archive = archive_with_active_sieve(
"loop",
b"require [\"fileinto\", \"vnd.stalwart.while\"];\nkeep;\n",
);
let (upload, activate, _keep) = mock_sieve_export(
&mut server,
session_body_sieve(
&base,
&["fileinto", "vnd.inbuxa.while", "vnd.inbuxa.expressions"],
),
Matcher::Exact("require [\"fileinto\", \"vnd.inbuxa.while\"];\nkeep;\n".into()),
true,
json!(["SieveScript/set", {"accountId":"w"}, "a"]),
);
let summary = sync::export::run(
common(&archive),
export_cfg_objects(&base, vec![ObjectType::SieveScript]),
)
.expect("export");
upload.assert();
activate.assert();
let c = sieve_counts(&summary);
assert_eq!((c.created, c.failed), (1, 0));
let _ = std::fs::remove_file(&archive);
}
#[test]
fn export_sieve_keeps_stalwart_names_for_a_target_without_inbuxa_names() {
let mut server = mockito::Server::new();
let base = server.url();
let archive = archive_with_active_sieve(
"loop",
b"require [\"fileinto\", \"vnd.stalwart.while\"];\nkeep;\n",
);
let (upload, _activate, _keep) = mock_sieve_export(
&mut server,
session_body_sieve(&base, &["fileinto", "vnd.stalwart.while"]),
Matcher::Regex("vnd\\.stalwart\\.while".into()),
true,
json!(["SieveScript/set", {"accountId":"w"}, "a"]),
);
let summary = sync::export::run(
common(&archive),
export_cfg_objects(&base, vec![ObjectType::SieveScript]),
)
.expect("export");
upload.assert();
assert_eq!(sieve_counts(&summary).failed, 0);
let _ = std::fs::remove_file(&archive);
}
#[test]
fn export_sieve_activation_error_is_a_failure() {
let mut server = mockito::Server::new();
let base = server.url();
let archive = archive_with_active_sieve("main", b"require [\"fileinto\"];\nkeep;\n");
let (_upload, activate, _keep) = mock_sieve_export(
&mut server,
session_body_full(&base),
Matcher::Any,
true,
json!(["error", {"type":"invalidArguments","description":"cannot activate"}, "a"]),
);
let summary = sync::export::run(
common(&archive),
export_cfg_objects(&base, vec![ObjectType::SieveScript]),
)
.expect("export");
activate.assert();
let c = sieve_counts(&summary);
assert_eq!(c.created, 1);
assert_eq!(
c.failed, 1,
"a failed activation is a failure, not a warning"
);
assert!(summary.any_failed(), "so export exits non-zero");
let _ = std::fs::remove_file(&archive);
}
#[test]
fn export_sieve_active_script_not_created_is_a_failure() {
let mut server = mockito::Server::new();
let base = server.url();
let archive = archive_with_active_sieve("main", b"require [\"nope\"];\nkeep;\n");
let (_upload, _activate, _keep) = mock_sieve_export(
&mut server,
session_body_full(&base),
Matcher::Any,
false,
json!(["SieveScript/set", {"accountId":"w"}, "a"]),
);
let summary = sync::export::run(
common(&archive),
export_cfg_objects(&base, vec![ObjectType::SieveScript]),
)
.expect("export");
let c = sieve_counts(&summary);
assert_eq!((c.created, c.failed), (0, 1));
assert!(summary.any_failed());
let _ = std::fs::remove_file(&archive);
}
#[test] #[test]
fn export_sieve_scripts_identical_content_different_names_both_created() { fn export_sieve_scripts_identical_content_different_names_both_created() {
let mut server = mockito::Server::new(); let mut server = mockito::Server::new();