Rename the identifiers that carried the upstream name
ci / fork-checks (pull_request) Successful in 16s
ci / build (pull_request) Successful in 7m53s

Everything clients, users and operators meet now carries the fork's name,
with no aliases (SPEC.md §2.4, changed here from "protocol identifiers
stay"):

- JMAP: upstream's registry capability is urn:inbuxa:jmap:registry, beside
  the fork's own urn:inbuxa:jmap.
- WebDAV lock and sync tokens are urn:inbuxa:dav*; clients resync once.
- Sieve: vnd.inbuxa.while and vnd.inbuxa.expressions. sieve-rs spells these
  into its compiler, so it's vendored (vendor/sieve-rs, 0.7.3) and patched in;
  a unit test fails if Cargo.lock ever moves past the vendored copy. The
  trusted runtime now names itself too, rather than answering sieve-rs's
  default.
- The web interface's OAuth client is inbuxa-webui. On every start the old
  stalwart-webui client is removed and any application naming it is moved
  over.
- The spam filter's blobs are INBUXA_SPAM_*; every start moves any left
  under the old keys, so a trained model survives.
- SQL stores and log files default to inbuxa, in the code and in the
  schema served to the admin (checksum regenerated).
- Settings are INBUXA_* only. A STALWART_* variable that's set where its
  INBUXA_* one isn't stops the server at startup, naming it.
- The version-upgrade messages link docs.inbuxa.org's migration page, and
  the OpenAPI description, smtp crate metadata and web-push test fixtures
  lose the name.

Kept on purpose, allowlisted with reasons: the OAuth key-derivation
contexts (renaming them would end every session and invalidate every
sealed client id) and the hashed application prefix.

Also fixes a latent start-up failure: ensure_client updated an existing
first-party client with a revision of 0, which the registry's assertion
never matches, so adding a redirect URI or changing the webmail secret
failed start-up. And the principal session test now expects
legacyProtocols (C-1, added 2026-09-21), which it had missed.

Tested: the server builds without warnings; common's 106 unit tests,
including the vendoring check; a new integration test for the two
start-up migrations; and the webdav, jmap, imap and SMTP Sieve suites.
This commit is contained in:
2026-09-22 19:33:02 -07:00
parent 4799d191a0
commit cc6f1eb298
129 changed files with 20504 additions and 168 deletions
+102
View File
@@ -0,0 +1,102 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use mail_parser::{
Encoding, Header, HeaderName, HeaderValue, MimeHeaders, PartType,
decoders::html::{html_to_text, text_to_html},
};
use crate::{
Context, compiler::grammar::actions::action_convert::Convert, runtime::tests::TestResult,
};
#[derive(Clone, Copy)]
enum Conversion {
TextToHtml,
TextPlainToHtml,
HtmlToText,
}
impl Convert {
pub(crate) fn exec(&self, ctx: &mut Context) -> TestResult {
let _from_media_type = ctx.eval_value(&self.from_media_type);
let _to_media_type = ctx.eval_value(&self.to_media_type);
let from_media_type = _from_media_type.to_string();
let to_media_type = _to_media_type.to_string();
if from_media_type.eq_ignore_ascii_case(to_media_type.as_ref()) {
return TestResult::Bool(false ^ self.is_not);
}
let conversion = if (from_media_type.eq_ignore_ascii_case("text")
|| from_media_type.starts_with("text/"))
&& to_media_type.eq_ignore_ascii_case("text/html")
{
if from_media_type.eq_ignore_ascii_case("text") {
Conversion::TextPlainToHtml
} else {
Conversion::TextToHtml
}
} else if from_media_type.eq_ignore_ascii_case("text/html")
&& to_media_type.eq_ignore_ascii_case("text/plain")
{
Conversion::HtmlToText
} else {
return TestResult::Bool(false ^ self.is_not);
};
let mut did_convert = false;
for part in ctx.message.parts.iter_mut() {
let (new_body, ct) = match (&part.body, conversion) {
(PartType::Html(html), Conversion::HtmlToText) => (
PartType::Text(html_to_text(html.as_ref()).into()),
"text/plain; charset=utf8",
),
(PartType::Text(text), Conversion::TextToHtml) => (
PartType::Html(text_to_html(text.as_ref()).into()),
"text/html; charset=utf8",
),
(PartType::Text(text), Conversion::TextPlainToHtml)
if part
.content_type()
.and_then(|ct| ct.c_subtype.as_ref())
.is_some_and(|st| st.eq_ignore_ascii_case("plain")) =>
{
(
PartType::Html(text_to_html(text.as_ref()).into()),
"text/html; charset=utf8",
)
}
_ => {
continue;
}
};
part.headers = vec![Header {
name: HeaderName::Other("Content-Type".into()),
value: HeaderValue::Text(ct.to_string().into()),
offset_start: 0,
offset_end: 0,
offset_field: 0,
}];
ctx.message_size = ctx.message_size + ct.len() + new_body.len() + 16
- (if part.offset_body != 0 {
(part.offset_end - part.offset_header) as usize
} else {
part.body.len()
});
part.offset_body = 0;
part.body = new_body;
part.encoding = Encoding::QuotedPrintable; //Used as non-mime flag
did_convert = true;
}
if did_convert {
ctx.has_changes = true;
}
TestResult::Bool(did_convert ^ self.is_not)
}
}
+184
View File
@@ -0,0 +1,184 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use std::borrow::Cow;
use mail_parser::{Header, HeaderName, HeaderValue};
use crate::{
Context,
compiler::grammar::{
MatchType,
actions::{
action_editheader::{AddHeader, DeleteHeader},
action_mime::MimeOpts,
},
},
};
impl AddHeader {
pub(crate) fn exec(&self, ctx: &mut Context) {
let header_name__ = ctx.eval_value(&self.field_name);
let header_name_ = header_name__.to_string();
let mut header_name = String::with_capacity(header_name_.len());
for ch in header_name_.chars() {
if ch.is_alphanumeric() || ch == '-' {
header_name.push(ch);
}
}
if !header_name.is_empty()
&& let Some(header_name) = HeaderName::parse(header_name)
&& !ctx.runtime.protected_headers.contains(&header_name)
{
ctx.has_changes = true;
ctx.insert_header(
ctx.part,
header_name,
ctx.eval_value(&self.value)
.to_string()
.as_ref()
.remove_crlf(ctx.runtime.max_header_size),
self.last,
)
}
}
}
impl DeleteHeader {
pub(crate) fn exec(&self, ctx: &mut Context) {
let header_name__ = ctx.eval_value(&self.field_name);
let header_name_ = header_name__.to_string();
let header_name = if let Some(header_name) = HeaderName::parse(header_name_.as_ref()) {
header_name
} else {
return;
};
let value_patterns = ctx.eval_values(&self.value_patterns);
let mut deleted_headers = Vec::new();
let mut deleted_bytes = 0;
if ctx.runtime.protected_headers.contains(&header_name) {
return;
}
ctx.find_headers(
&[header_name],
self.index,
self.mime_anychild,
|header, part_id, header_pos| {
if !value_patterns.is_empty() {
let did_match = ctx.find_header_values(header, &MimeOpts::None, |value| {
for (pattern_expr, pattern) in
value_patterns.iter().zip(self.value_patterns.iter())
{
if match &self.match_type {
MatchType::Is => self.comparator.is(&value, pattern_expr),
MatchType::Contains => self
.comparator
.contains(value, pattern_expr.to_string().as_ref()),
MatchType::Value(rel_match) => {
self.comparator.relational(rel_match, &value, pattern_expr)
}
MatchType::Matches(_) => self.comparator.matches(
value,
pattern_expr.to_string().as_ref(),
0,
&mut Vec::new(),
),
MatchType::Regex(_) => self.comparator.regex(
pattern,
pattern_expr,
value,
0,
&mut Vec::new(),
),
MatchType::Count(_) => false,
MatchType::List => false,
} {
return true;
}
}
false
});
if !did_match {
return false;
}
}
if header.offset_end != 0 {
deleted_bytes += (header.offset_end - header.offset_field) as usize;
} else {
deleted_bytes += header.name.as_str().len() + header.value.len() + 4;
}
deleted_headers.push((part_id, header_pos));
false
},
);
if !deleted_headers.is_empty() {
ctx.has_changes = true;
for (part_id, header_pos) in deleted_headers.iter().rev() {
ctx.message.parts[*part_id as usize]
.headers
.remove(*header_pos);
}
}
ctx.message_size -= deleted_bytes;
}
}
pub(crate) trait RemoveCrLf {
fn remove_crlf(&self, max_len: usize) -> String;
}
impl RemoveCrLf for &str {
fn remove_crlf(&self, max_len: usize) -> String {
let mut header_value = String::with_capacity(self.len());
for ch in self.chars() {
if !['\n', '\r'].contains(&ch) {
if header_value.len() + ch.len_utf8() <= max_len {
header_value.push(ch);
} else {
return header_value;
}
}
}
header_value
}
}
impl<'x> Context<'x> {
pub(crate) fn insert_header(
&mut self,
part_id: u32,
header_name: HeaderName<'x>,
header_value: impl Into<Cow<'static, str>>,
last: bool,
) {
let header_value = header_value.into();
self.message_size += header_name.len() + header_value.len() + 4;
let header = Header {
name: header_name,
value: HeaderValue::Text(header_value),
offset_start: 0,
offset_end: 0,
offset_field: 0,
};
if !last {
self.message.parts[part_id as usize]
.headers
.insert(0, header);
} else {
self.message.parts[part_id as usize].headers.push(header);
}
}
}
+40
View File
@@ -0,0 +1,40 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{Context, Event, compiler::grammar::actions::action_fileinto::FileInto};
impl FileInto {
pub(crate) fn exec(&self, ctx: &mut Context) {
let folder = ctx.eval_value(&self.folder).to_string().into_owned();
let mut events = Vec::with_capacity(2);
if let Some(event) = ctx.build_message_id() {
events.push(event);
}
if !self.copy
&& !matches!(&ctx.final_event, Some(Event::Keep { flags, .. }) if !flags.is_empty())
{
ctx.final_event = None;
}
events.push(Event::FileInto {
folder,
flags: ctx.get_local_or_global_flags(&self.flags),
mailbox_id: self
.mailbox_id
.as_ref()
.map(|mi| ctx.eval_value(mi).to_string().into_owned()),
special_use: self
.special_use
.as_ref()
.map(|su| ctx.eval_value(su).to_string().into_owned()),
create: self.create,
message_id: ctx.main_message_id,
});
ctx.queued_events = events.into_iter();
}
}
+140
View File
@@ -0,0 +1,140 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
Context,
compiler::{
Value, VariableType,
grammar::actions::action_flags::{Action, EditFlags},
},
};
impl EditFlags {
pub(crate) fn exec(&self, ctx: &mut Context) {
let mut var_name_ = None;
let var_name = self.name.as_ref().unwrap_or_else(|| {
var_name_.get_or_insert_with(|| VariableType::Global("__flags".to_string()))
});
match &self.action {
Action::Set => {
let mut flags_lc = Vec::new();
let mut flags = String::new();
ctx.tokenize_flags(&self.flags, |flag| {
let flag_lc = flag.to_lowercase();
if !flags_lc.contains(&flag_lc) {
if !flags.is_empty() {
flags.push(' ');
}
flags.push_str(flag);
flags_lc.push(flag_lc);
}
false
});
ctx.set_variable(var_name, flags.into());
}
Action::Add => {
let mut new_flags = ctx
.get_variable(var_name)
.map(|v| v.to_string())
.unwrap_or_default()
.into_owned();
let mut current_flags = new_flags
.split(' ')
.map(|f| f.to_lowercase())
.collect::<Vec<_>>();
ctx.tokenize_flags(&self.flags, |flag| {
let flag_lc = flag.to_lowercase();
if !current_flags.contains(&flag_lc) {
if !new_flags.is_empty() {
new_flags.push(' ');
}
new_flags.push_str(flag);
current_flags.push(flag_lc);
}
false
});
ctx.set_variable(var_name, new_flags.into());
}
Action::Remove => {
let mut current_flags = Vec::new();
let mut current_flags_lc = Vec::new();
let flags = ctx
.get_variable(var_name)
.map(|v| v.to_string().into_owned())
.unwrap_or_default();
for flag in flags.split(' ') {
current_flags.push(flag);
current_flags_lc.push(flag.to_lowercase());
}
ctx.tokenize_flags(&self.flags, |flag| {
let flag = flag.to_lowercase();
if let Some(pos) = current_flags_lc.iter().position(|lflag| lflag == &flag) {
current_flags.swap_remove(pos);
current_flags_lc.swap_remove(pos);
}
false
});
ctx.set_variable(var_name, current_flags.join(" ").into());
}
}
}
}
impl Context<'_> {
pub(crate) fn tokenize_flags(
&self,
strings: &[Value],
mut cb: impl FnMut(&str) -> bool,
) -> bool {
for (pos, string) in strings.iter().enumerate() {
let flag_ = self.eval_value(string);
let flag = flag_.to_string();
if !flag.is_empty() {
if pos == 0 && strings.len() == 1 {
for flag in flag.split_ascii_whitespace() {
if !flag.is_empty() && cb(flag) {
return true;
}
}
} else if cb(flag.trim()) {
return true;
}
}
}
false
}
pub(crate) fn get_local_flags(&self, strings: &[Value]) -> Vec<String> {
let mut flags = Vec::new();
self.tokenize_flags(strings, |flag| {
flags.push(flag.to_string());
false
});
flags
}
pub(crate) fn get_global_flags(&self) -> Vec<String> {
match self.vars_global.get("__flags") {
Some(flags) if !flags.is_empty() => flags
.to_string()
.split(' ')
.map(|s| s.to_string())
.collect::<Vec<String>>(),
_ => Vec::new(),
}
}
pub(crate) fn get_local_or_global_flags(&self, strings: &[Value]) -> Vec<String> {
if strings.is_empty() {
self.get_global_flags()
} else {
self.get_local_flags(strings)
}
}
}
+53
View File
@@ -0,0 +1,53 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use std::sync::Arc;
use crate::{
Context, Event, Script, Sieve,
compiler::grammar::actions::action_include::{Include, Location},
runtime::RuntimeError,
};
pub(crate) enum IncludeResult {
Cached(Arc<Sieve>),
Event(Event),
Error(RuntimeError),
None,
}
impl Include {
pub(crate) fn exec(&self, ctx: &Context) -> IncludeResult {
let script_name = ctx.eval_value(&self.value);
if !script_name.is_empty() {
let script_name = if self.location == Location::Global {
Script::Global(script_name.to_string().into_owned())
} else {
Script::Personal(script_name.to_string().into_owned())
};
let cached_script = ctx.script_cache.get(&script_name);
if !self.once || cached_script.is_none() {
if ctx.script_stack.len() < ctx.runtime.max_nested_includes {
if let Some(script) = cached_script
.or_else(|| ctx.runtime.include_scripts.get(script_name.as_str()))
{
return IncludeResult::Cached(script.clone());
} else {
return IncludeResult::Event(Event::IncludeScript {
name: script_name,
optional: self.optional,
});
}
} else {
return IncludeResult::Error(RuntimeError::TooManyIncludes);
}
}
}
IncludeResult::None
}
}
+582
View File
@@ -0,0 +1,582 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use std::cmp::Reverse;
use mail_parser::{
Encoding, HeaderName, Message, MessagePart, PartType, decoders::html::html_to_text,
};
use crate::{
Context, Event,
compiler::{
VariableType,
grammar::actions::action_mime::{Enclose, ExtractText, Replace},
},
};
use super::action_editheader::RemoveCrLf;
#[cfg(not(test))]
use mail_builder::headers::message_id::generate_message_id_header;
impl Replace {
pub(crate) fn exec(&self, ctx: &mut Context) {
// Delete children parts
let mut part_ids = ctx.find_nested_parts_ids(false);
part_ids.sort_unstable_by_key(|a| Reverse(*a));
for part_id in part_ids {
ctx.message.parts.remove(part_id as usize);
}
ctx.has_changes = true;
// Update part
let body = ctx.eval_value(&self.replacement).to_string().into_owned();
let body_len = body.len();
let part = &mut ctx.message.parts[ctx.part as usize];
ctx.message_size = ctx.message_size + body_len
- (if part.offset_body != 0 {
(part.offset_end - part.offset_header) as usize
} else {
part.body.len()
});
part.body = PartType::Text(body.into());
part.encoding = if !self.mime {
Encoding::QuotedPrintable
} else {
Encoding::None
};
part.offset_body = 0;
let prev_headers = std::mem::take(&mut part.headers);
let mut add_date = true;
let mut has_original_from = false;
if ctx.part == 0 {
for mut header in prev_headers {
let mut size = (header.offset_end - header.offset_field) as usize;
match &header.name {
HeaderName::Subject => {
if self.subject.is_some() {
header.name = HeaderName::Other("Original-Subject".into());
header.offset_field = header.offset_start;
size += "Original-".len();
}
}
HeaderName::From => {
if self.from.is_some() {
header.name = HeaderName::Other("Original-From".into());
header.offset_field = header.offset_start;
size += "Original-".len();
} else {
has_original_from = true;
}
}
HeaderName::To | HeaderName::Cc | HeaderName::Bcc | HeaderName::Received => (),
HeaderName::Date => {
add_date = false;
}
_ => continue,
}
ctx.message_size += size;
part.headers.push(header);
}
// Add From
let mut add_from = true;
if let Some(from) = self.from.as_ref().map(|f| ctx.eval_value(f))
&& !from.is_empty()
{
ctx.insert_header(
0,
HeaderName::Other("From".into()),
from.to_string()
.as_ref()
.remove_crlf(ctx.runtime.max_header_size),
true,
);
add_from = false;
}
if add_from && !has_original_from {
ctx.insert_header(
0,
HeaderName::Other("From".to_string().into()),
ctx.user_from_field(),
true,
);
}
// Add Subject
if let Some(subject) = self.subject.as_ref().map(|f| ctx.eval_value(f))
&& !subject.is_empty()
{
ctx.insert_header(
0,
HeaderName::Other("Subject".into()),
subject
.to_string()
.as_ref()
.remove_crlf(ctx.runtime.max_header_size),
true,
);
}
// Add Date
if add_date {
#[cfg(not(test))]
let header_value = mail_builder::headers::date::Date::now().to_rfc822();
#[cfg(test)]
let header_value = "Tue, 20 Nov 2022 05:14:20 -0300".to_string();
ctx.insert_header(
0,
HeaderName::Other("Date".to_string().into()),
header_value,
true,
);
}
// Add Message-ID
let mut header_value = Vec::with_capacity(20);
#[cfg(not(test))]
generate_message_id_header(&mut header_value, &ctx.runtime.local_hostname).unwrap();
#[cfg(test)]
header_value.extend_from_slice(b"<auto-generated@message-id>");
ctx.insert_header(
0,
HeaderName::Other("Message-ID".to_string().into()),
String::from_utf8(header_value).unwrap(),
true,
);
}
if !self.mime {
ctx.insert_header(
ctx.part,
HeaderName::Other("Content-Type".into()),
"text/plain; charset=utf-8".to_string(),
true,
);
}
}
}
impl Enclose {
pub(crate) fn exec(&self, ctx: &mut Context) {
let body = ctx.eval_value(&self.value).to_string().into_owned();
let subject = self
.subject
.as_ref()
.map(|s| {
ctx.eval_value(s)
.to_string()
.as_ref()
.remove_crlf(ctx.runtime.max_header_size)
})
.or_else(|| ctx.message.subject().map(|s| s.to_string()))
.unwrap_or_default();
let message = std::mem::take(&mut ctx.message);
#[cfg(test)]
let boundary = make_test_boundary();
#[cfg(not(test))]
let boundary = mail_builder::mime::make_boundary(".");
ctx.message_size += ((boundary.len() + 6) * 3) + body.len() + 2;
ctx.part = 0;
ctx.has_changes = true;
ctx.message = Message {
html_body: Vec::with_capacity(0),
text_body: Vec::with_capacity(0),
attachments: Vec::with_capacity(0),
parts: vec![
MessagePart {
headers: vec![],
is_encoding_problem: false,
body: PartType::Multipart(vec![1, 2]),
encoding: Encoding::None,
offset_header: 0,
offset_body: 0,
offset_end: 0,
},
MessagePart {
headers: vec![],
is_encoding_problem: false,
body: PartType::Text(body.into()),
encoding: Encoding::QuotedPrintable, // Flag non-mime part
offset_header: 0,
offset_body: 0,
offset_end: 0,
},
MessagePart {
headers: vec![],
is_encoding_problem: false,
body: PartType::Message(message),
encoding: Encoding::QuotedPrintable, // Flag non-mime part
offset_header: 0,
offset_body: 0,
offset_end: 0,
},
],
raw_message: b""[..].into(),
};
ctx.insert_header(
0,
HeaderName::Other("Content-Type".into()),
format!("multipart/mixed; boundary=\"{boundary}\""),
true,
);
ctx.insert_header(0, HeaderName::Other("Subject".into()), subject, true);
ctx.insert_header(
1,
HeaderName::Other("Content-Type".into()),
"text/plain; charset=utf-8",
true,
);
ctx.insert_header(
2,
HeaderName::Other("Content-Type".into()),
"message/rfc822",
true,
);
let mut add_date = true;
let mut add_message_id = true;
let mut add_from = true;
for header in &self.headers {
let header = ctx.eval_value(header);
if let Some((mut header_name, mut header_value)) =
header.to_string().as_ref().split_once(':')
{
header_name = header_name.trim();
header_value = header_value.trim();
if !header_value.is_empty()
&& let Some(name) = HeaderName::parse(header_name)
&& !ctx.runtime.protected_headers.contains(&name)
{
match &name {
HeaderName::Date => {
add_date = false;
}
HeaderName::From => {
add_from = false;
}
HeaderName::MessageId => {
add_message_id = false;
}
_ => (),
}
ctx.insert_header(
0,
HeaderName::Other(header_name.to_string().into()),
header_value.remove_crlf(ctx.runtime.max_header_size),
true,
);
}
}
}
if add_from {
ctx.insert_header(
0,
HeaderName::Other("From".to_string().into()),
ctx.user_from_field(),
true,
);
}
if add_date {
#[cfg(not(test))]
let header_value = mail_builder::headers::date::Date::now().to_rfc822();
#[cfg(test)]
let header_value = "Tue, 20 Nov 2022 05:14:20 -0300".to_string();
ctx.insert_header(
0,
HeaderName::Other("Date".to_string().into()),
header_value,
true,
);
}
if add_message_id {
let mut header_value = Vec::with_capacity(20);
#[cfg(not(test))]
generate_message_id_header(&mut header_value, &ctx.runtime.local_hostname).unwrap();
#[cfg(test)]
header_value.extend_from_slice(b"<auto-generated@message-id>");
ctx.insert_header(
0,
HeaderName::Other("Message-ID".to_string().into()),
String::from_utf8(header_value).unwrap(),
true,
);
}
}
}
impl ExtractText {
pub(crate) fn exec(&self, ctx: &mut Context) {
let mut value = String::new();
if !ctx.part_iter_stack.is_empty() {
match ctx.message.parts.get(ctx.part as usize).map(|p| &p.body) {
Some(PartType::Text(text)) => {
value = if let Some(first) = &self.first {
text.chars().take(*first).collect()
} else {
text.as_ref().to_string()
};
}
Some(PartType::Html(html)) => {
value = if let Some(first) = &self.first {
html_to_text(html.as_ref()).chars().take(*first).collect()
} else {
html_to_text(html.as_ref())
};
}
_ => (),
}
if !self.modifiers.is_empty() && !value.is_empty() {
for modifier in &self.modifiers {
value = modifier.apply(&value, ctx);
}
}
}
match &self.name {
VariableType::Local(var_id) => {
if let Some(var) = ctx.vars_local.get_mut(*var_id) {
*var = value.into();
} else {
debug_assert!(false, "Non-existent local variable {var_id}");
}
}
VariableType::Global(var_name) => {
ctx.vars_global
.insert(var_name.to_string().into(), value.into());
}
VariableType::Envelope(env) => {
ctx.add_set_envelope_event(*env, value);
}
_ => (),
}
}
}
enum StackItem<'x> {
Message(&'x Message<'x>),
Boundary(&'x str),
None,
}
impl Context<'_> {
pub(crate) fn build_message_id(&mut self) -> Option<Event> {
if self.has_changes {
self.last_message_id += 1;
self.main_message_id = self.last_message_id;
self.has_changes = false;
let message = self.build_message();
Some(Event::CreatedMessage {
message_id: self.main_message_id,
message,
})
} else {
None
}
}
pub(crate) fn build_message(&mut self) -> Vec<u8> {
let mut current_message = &self.message;
let mut current_boundary = "";
let mut message = Vec::with_capacity(self.message_size);
let mut iter = [0u32].iter();
let mut iter_stack = Vec::new();
let mut last_offset = 0;
'outer: loop {
while let Some(part) = iter
.next()
.and_then(|p| current_message.parts.get(*p as usize))
{
if last_offset > 0 {
message.extend_from_slice(
&current_message.raw_message
[last_offset as usize..part.offset_header as usize],
);
} else if !current_boundary.is_empty()
&& part.offset_end == 0
&& !matches!(iter_stack.last(), Some((StackItem::Message(_), _, _)))
{
message.extend_from_slice(b"\r\n--");
message.extend_from_slice(current_boundary.as_bytes());
message.extend_from_slice(b"\r\n");
}
let mut ct_pos = usize::MAX;
for (header_pos, header) in part.headers.iter().enumerate() {
if header.offset_end != 0 {
if header.offset_field != header.offset_start {
message.extend_from_slice(
&current_message.raw_message
[header.offset_field as usize..header.offset_end as usize],
);
} else {
// Renamed header
message.extend_from_slice(header.name.as_str().as_bytes());
message.extend_from_slice(b":");
message.extend_from_slice(
&current_message.raw_message
[header.offset_start as usize..header.offset_end as usize],
);
}
} else {
if header.name == HeaderName::Other("Content-Type".into()) {
ct_pos = header_pos;
}
message.extend_from_slice(header.name.as_str().as_bytes());
message.extend_from_slice(b": ");
message.extend_from_slice(header.value.as_text().unwrap_or("").as_bytes());
message.extend_from_slice(b"\r\n");
}
}
if part.offset_body != 0 || part.encoding != Encoding::None {
// Add CRLF unless this is a :mime replaced part
message.extend_from_slice(b"\r\n");
}
if part.offset_body != 0 {
// Original message part
if let PartType::Multipart(subparts) = &part.body {
// Multiparts contain offsets of the entire part, do not add.
iter_stack.push((
StackItem::None,
part,
std::mem::replace(&mut iter, subparts.iter()),
));
last_offset = part.offset_body;
continue 'outer;
} else {
message.extend_from_slice(
&current_message.raw_message
[part.offset_body as usize..part.offset_end as usize],
)
}
} else {
match &part.body {
PartType::Message(nested_message) => {
// Enclosed message
iter_stack.push((
StackItem::Message(current_message),
part,
std::mem::replace(&mut iter, [0].iter()),
));
current_message = nested_message;
continue 'outer;
}
PartType::Multipart(subparts) => {
// Multipart enclosing nested message, obtain MIME boundary
let prev_boundary = std::mem::replace(
&mut current_boundary,
if ct_pos != usize::MAX {
part.headers[ct_pos]
.value
.as_text()
.and_then(|h| h.split_once("boundary=\""))
.and_then(|(_, h)| h.split_once('\"'))
.map(|(h, _)| h)
} else {
None
}
.unwrap_or("invalid-boundary"),
);
// Enclose multipart
iter_stack.push((
StackItem::Boundary(prev_boundary),
part,
std::mem::replace(&mut iter, subparts.iter()),
));
continue 'outer;
}
_ => {
// Replaced part
message.extend_from_slice(part.contents());
}
}
}
last_offset = part.offset_end;
}
if let Some((prev_item, prev_part, prev_iter)) = iter_stack.pop() {
match prev_item {
StackItem::Message(prev_message) => {
if last_offset > 0 {
if let Some(bytes) =
current_message.raw_message.get(last_offset as usize..)
{
message.extend_from_slice(bytes);
}
last_offset = 0;
}
current_message = prev_message;
}
StackItem::Boundary(prev_boundary) => {
if !current_boundary.is_empty() {
message.extend_from_slice(b"\r\n--");
message.extend_from_slice(current_boundary.as_bytes());
message.extend_from_slice(b"--\r\n");
}
current_boundary = prev_boundary;
}
StackItem::None => {
message.extend_from_slice(
&current_message.raw_message
[last_offset as usize..prev_part.offset_end as usize],
);
last_offset = prev_part.offset_end;
}
}
iter = prev_iter;
} else {
break;
}
}
if last_offset > 0
&& let Some(bytes) = current_message.raw_message.get(last_offset as usize..)
{
message.extend_from_slice(bytes);
}
message
}
}
#[cfg(test)]
thread_local!(static COUNTER: std::cell::Cell<u64> = 0.into());
#[cfg(test)]
pub(crate) fn make_test_boundary() -> String {
format!("boundary_{}", COUNTER.with(|c| { c.replace(c.get() + 1) }))
}
#[cfg(test)]
pub(crate) fn reset_test_boundary() {
COUNTER.with(|c| c.replace(0));
}
+575
View File
@@ -0,0 +1,575 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use mail_builder::headers::{date::Date, message_id::generate_message_id_header};
use mail_parser::{HeaderName, decoders::quoted_printable::HEX_MAP};
use crate::{
Context, Event, Importance, Recipient,
compiler::grammar::actions::{
action_notify::Notify,
action_redirect::{ByTime, Ret},
},
};
use super::action_vacation::MAX_SUBJECT_LEN;
impl Notify {
pub(crate) fn exec(&self, ctx: &mut Context) {
// Do not notify on Auto-Submitted messages
for header in &ctx.message.parts[0].headers {
if matches!(&header.name, HeaderName::Other(name) if name.eq_ignore_ascii_case("Auto-Submitted"))
&& header
.value
.as_text()
.is_none_or(|v| !v.eq_ignore_ascii_case("no"))
{
return;
}
}
let uri = ctx.eval_value(&self.method).to_string().into_owned();
let (scheme, params) = if let Some(parts) = parse_uri(&uri) {
parts
} else {
return;
};
let has_fcc = self.fcc.is_some();
let is_mailto = scheme.eq_ignore_ascii_case("mailto")
&& ctx.num_out_messages < ctx.runtime.max_out_messages;
let mut events = Vec::with_capacity(3);
if is_mailto || has_fcc {
let params = if is_mailto {
if let Some(params) = parse_mailto(params) {
params
} else {
return;
}
} else {
MailtoMessage {
to: Vec::new(),
cc: Vec::new(),
bcc: Vec::new(),
body: None,
headers: Vec::new(),
}
};
let from = if let Some(from) = &self.from {
let from = ctx.eval_value(from).to_string().into_owned();
if from
.to_ascii_lowercase()
.contains(&ctx.user_address.to_ascii_lowercase())
{
from
} else {
ctx.user_from_field()
}
} else {
ctx.user_from_field()
};
let notify_message = self
.message
.as_ref()
.map(|m| ctx.eval_value(m).to_string().into_owned());
let message_len = params
.to
.iter()
.chain(params.cc.iter())
.map(|a| a.len() + 4)
.sum::<usize>()
+ params
.headers
.iter()
.map(|(h, v)| h.len() + v.len() + 4)
.sum::<usize>()
+ params.body.as_ref().map_or(0, |b| b.len())
+ notify_message.as_ref().map_or(0, |b| b.len())
+ from.len()
+ 200;
let mut message = Vec::with_capacity(message_len);
message.extend_from_slice(b"From: ");
message.extend_from_slice(from.as_bytes());
message.extend_from_slice(b"\r\n");
for (header, addresses) in [("To: ", &params.to), ("Cc: ", &params.cc)] {
if !addresses.is_empty() {
message.extend_from_slice(header.as_bytes());
for (pos, address) in addresses.iter().enumerate() {
if pos > 0 {
message.extend_from_slice(b", ");
}
if !address.contains('<') {
message.push(b'<');
}
message.extend_from_slice(address.as_bytes());
if !address.contains('<') {
message.push(b'>');
}
}
message.extend_from_slice(b"\r\n");
}
}
let mut has_subject = None;
let mut has_date = false;
let mut has_message_id = false;
for (header, value) in &params.headers {
match header {
HeaderName::Subject => {
has_subject = value.into();
continue;
}
HeaderName::Date => {
has_date = true;
}
HeaderName::MessageId => {
has_message_id = true;
}
HeaderName::From => {
continue;
}
_ => (),
}
message.extend_from_slice(header.as_str().as_bytes());
message.extend_from_slice(b": ");
message.extend_from_slice(value.as_bytes());
message.extend_from_slice(b"\r\n");
}
if !has_date {
message.extend_from_slice(b"Date: ");
message.extend_from_slice(Date::now().to_rfc822().as_bytes());
message.extend_from_slice(b"\r\n");
}
if !has_message_id {
message.extend_from_slice(b"Message-ID: ");
generate_message_id_header(&mut message, &ctx.runtime.local_hostname).unwrap();
message.extend_from_slice(b"\r\n");
}
let (importance, priority) =
self.importance
.as_ref()
.map_or(("Normal", "3 (Normal)"), |i| {
match ctx.eval_value(i).to_string().as_ref() {
"1" => ("High", "1 (High)"),
"3" => ("Low", "5 (Low)"),
_ => ("Normal", "3 (Normal)"),
}
});
message.extend_from_slice(b"Importance: ");
message.extend_from_slice(importance.as_bytes());
message.extend_from_slice(b"\r\n");
message.extend_from_slice(b"X-Priority: ");
message.extend_from_slice(priority.as_bytes());
message.extend_from_slice(b"\r\n");
message.extend_from_slice(b"Subject: ");
let subject = if let Some(subject) = has_subject {
subject.as_str()
} else if let Some(subject) = &notify_message {
subject.as_ref()
} else {
ctx.message.subject().unwrap_or_default()
};
let mut iter = subject.chars().enumerate();
let mut buf = [0; 4];
#[allow(clippy::while_let_on_iterator)]
while let Some((pos, char)) = iter.next() {
if pos < MAX_SUBJECT_LEN {
message.extend_from_slice(char.encode_utf8(&mut buf).as_bytes());
} else {
break;
}
}
if iter.next().is_some() {
message.extend_from_slice('…'.encode_utf8(&mut buf).as_bytes());
}
message.extend_from_slice(b"\r\n");
message.extend_from_slice(b"Auto-Submitted: auto-notified\r\n");
message.extend_from_slice(b"X-Sieve: yes\r\n");
message.extend_from_slice(b"Content-type: text/plain; charset=utf-8\r\n\r\n");
if let Some(body) = params.body {
message.extend_from_slice(body.as_bytes());
} else if let Some(subject) = &notify_message {
message.extend_from_slice(subject.as_bytes());
} else if let Some(subject) = ctx.message.subject() {
message.extend_from_slice(subject.as_bytes());
}
ctx.last_message_id += 1;
events.push(Event::CreatedMessage {
message_id: ctx.last_message_id,
message,
});
if is_mailto {
events.push(Event::SendMessage {
recipient: Recipient::Group(
params
.to
.into_iter()
.chain(params.cc)
.chain(params.bcc)
.map(|addr| {
if let Some((addr, _)) = addr
.rsplit_once('<')
.and_then(|(_, addr)| addr.rsplit_once('>'))
{
addr.to_string()
} else {
addr
}
})
.collect(),
),
notify: crate::compiler::grammar::actions::action_redirect::Notify::Never,
return_of_content: Ret::Default,
by_time: ByTime::None,
message_id: ctx.last_message_id,
});
}
}
if !is_mailto {
events.push(Event::Notify {
method: uri,
from: self
.from
.as_ref()
.map(|f| ctx.eval_value(f).to_string().into_owned()),
importance: self.importance.as_ref().map_or(Importance::Normal, |i| {
match ctx.eval_value(i).to_string().as_ref() {
"1" => Importance::High,
"3" => Importance::Low,
_ => Importance::Normal,
}
}),
options: ctx.eval_values_owned(&self.options),
message: self
.message
.as_ref()
.map(|m| ctx.eval_value(m).to_string().into_owned())
.or_else(|| ctx.message.subject().map(|s| s.to_string()))
.unwrap_or_default(),
});
ctx.num_out_messages += 1;
}
if let Some(fcc) = &self.fcc {
// File carbon copy
events.push(Event::FileInto {
folder: ctx.eval_value(&fcc.mailbox).to_string().into_owned(),
flags: ctx.get_local_flags(&fcc.flags),
mailbox_id: fcc
.mailbox_id
.as_ref()
.map(|m| ctx.eval_value(m).to_string().into_owned()),
special_use: fcc
.special_use
.as_ref()
.map(|s| ctx.eval_value(s).to_string().into_owned()),
create: fcc.create,
message_id: ctx.last_message_id,
});
}
ctx.queued_events = events.into_iter();
}
}
pub fn validate_from(addr: &str) -> bool {
let mut has_at = false;
let mut has_dot = false;
let mut in_quote = false;
let mut in_angle = false;
let mut last_ch = 0;
for &ch in addr.as_bytes().iter() {
match ch {
b'\"' if last_ch != b'\\' => {
in_quote = !in_quote;
}
b'<' if !in_quote => {
if !in_angle {
in_angle = true;
has_at = false;
has_dot = false;
} else {
return false;
}
}
b'>' if !in_quote => {
if in_angle {
in_angle = false;
} else {
return false;
}
}
b'@' if !in_quote => {
if !has_at && last_ch.is_ascii_alphanumeric() {
has_at = true;
} else {
return false;
}
}
b'.' if !in_quote && has_at => {
has_dot = true;
}
_ => (),
}
last_ch = ch;
}
has_dot && has_at && !in_angle
}
pub fn validate_uri(uri: &str) -> Option<&str> {
let (scheme, uri) = parse_uri(uri)?;
if scheme.eq_ignore_ascii_case("mailto") {
parse_mailto(uri)?;
scheme.into()
} else if ["xmpp", "tel", "http", "https"].contains(&scheme) {
scheme.into()
} else {
None
}
}
pub(crate) fn parse_uri(uri: &str) -> Option<(&str, &str)> {
let (scheme, uri) = uri.split_once(':')?;
if !uri.is_empty() {
Some((scheme, uri))
} else {
None
}
}
pub enum Mailto {
Header(HeaderName<'static>),
Body,
Other(String),
}
enum State {
Address((HeaderName<'static>, bool)),
ParamName,
ParamValue(Mailto),
}
#[derive(Default)]
struct MailtoMessage {
to: Vec<String>,
cc: Vec<String>,
bcc: Vec<String>,
body: Option<String>,
headers: Vec<(HeaderName<'static>, String)>,
}
fn parse_mailto(uri: &str) -> Option<MailtoMessage> {
let mut params = MailtoMessage::default();
let mut state = State::Address((HeaderName::To, false));
let mut buf = Vec::new();
let uri_ = uri.as_bytes();
let mut iter = uri_.iter();
let mut has_addresses = false;
while let Some(&ch) = iter.next() {
match ch {
b'%' => {
let hex1 = HEX_MAP[*iter.next()? as usize];
let hex2 = HEX_MAP[*iter.next()? as usize];
if hex1 != -1 && hex2 != -1 {
let ch = ((hex1 as u8) << 4) | hex2 as u8;
match &state {
State::Address((header, has_at)) => match ch {
b',' => {
if *has_at {
insert_address(
&mut params,
header.clone(),
String::from_utf8(std::mem::take(&mut buf)).ok()?,
);
has_addresses = true;
state = State::Address((header.clone(), false));
} else {
return None;
}
}
b'@' => {
if !*has_at {
state = State::Address((header.clone(), true));
buf.push(ch);
} else {
return None;
}
}
_ => {
buf.push(ch);
}
},
_ => buf.push(ch),
}
} else {
return None;
}
}
b',' => match &state {
State::Address((header, true)) => {
insert_address(
&mut params,
header.clone(),
String::from_utf8(std::mem::take(&mut buf)).ok()?,
);
state = State::Address((header.clone(), false));
has_addresses = true;
}
State::ParamValue(_) => buf.push(ch),
_ => return None,
},
b'?' => match &state {
State::Address((header, has_at)) if *has_at || buf.is_empty() => {
if !buf.is_empty() {
insert_address(
&mut params,
header.clone(),
String::from_utf8(std::mem::take(&mut buf)).ok()?,
);
has_addresses = true;
}
state = State::ParamName;
}
State::ParamValue(_) => buf.push(ch),
_ => return None,
},
b'@' => match &state {
State::Address((header, false)) if !buf.is_empty() => {
buf.push(ch);
state = State::Address((header.clone(), true));
}
State::ParamName | State::ParamValue(_) => buf.push(ch),
_ => return None,
},
b'=' => match &state {
State::ParamName if !buf.is_empty() => {
let param = String::from_utf8(std::mem::take(&mut buf)).ok()?;
state = HeaderName::parse(param)
.map(|hdr| match hdr {
HeaderName::To | HeaderName::Cc | HeaderName::Bcc => {
State::Address((hdr, false))
}
HeaderName::Other(param) => {
if param.eq_ignore_ascii_case("body") {
State::ParamValue(Mailto::Body)
} else {
State::ParamValue(Mailto::Other(param.into_owned()))
}
}
_ => State::ParamValue(Mailto::Header(hdr)),
})
.unwrap_or_else(|| State::ParamValue(Mailto::Other(String::new())));
}
State::ParamValue(_) => buf.push(ch),
_ => return None,
},
b'&' => match state {
State::Address((header, true)) => {
if !buf.is_empty() {
insert_address(
&mut params,
header,
String::from_utf8(std::mem::take(&mut buf)).ok()?,
);
}
state = State::ParamName;
}
State::ParamValue(param) => {
if !buf.is_empty() {
let value = String::from_utf8(std::mem::take(&mut buf)).ok()?;
match param {
Mailto::Header(header) => params.headers.push((header, value)),
Mailto::Body => params.body = value.into(),
Mailto::Other(header) => params.headers.push((header.into(), value)),
}
}
state = State::ParamName;
}
_ => return None,
},
_ => match &state {
State::ParamName => {
if ch.is_ascii_alphanumeric() || b"-_".contains(&ch) {
buf.push(ch);
} else {
return None;
}
}
_ => {
if !ch.is_ascii_whitespace() {
buf.push(ch);
}
}
},
}
}
if !buf.is_empty() {
let value = String::from_utf8(std::mem::take(&mut buf)).ok()?;
match state {
State::Address((header, true)) => {
insert_address(&mut params, header, value);
has_addresses = true;
}
State::ParamName => {
params
.headers
.push((HeaderName::Other(value.into()), String::new()));
}
State::ParamValue(param) => match param {
Mailto::Header(header) => params.headers.push((header, value)),
Mailto::Body => params.body = value.into(),
Mailto::Other(header) => params
.headers
.push((HeaderName::Other(header.into()), value)),
},
_ => return None,
}
}
if has_addresses { Some(params) } else { None }
}
#[inline(always)]
fn insert_address(params: &mut MailtoMessage, name: HeaderName, value: String) {
if !params
.to
.iter()
.chain(params.cc.iter())
.chain(params.bcc.iter())
.any(|v| v.eq_ignore_ascii_case(&value))
{
match name {
HeaderName::To => {
params.to.push(value);
}
HeaderName::Cc => {
params.cc.push(value);
}
HeaderName::Bcc => {
params.bcc.push(value);
}
_ => (),
}
}
}
+135
View File
@@ -0,0 +1,135 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use mail_parser::{DateTime, HeaderName};
use crate::{
Context, Event, Recipient,
compiler::grammar::actions::action_redirect::{ByTime, Redirect},
};
impl Redirect {
pub(crate) fn exec(&self, ctx: &mut Context) {
if let Some(address) = sanitize_address(ctx.eval_value(&self.address).to_string().as_ref())
&& ctx.num_redirects < ctx.runtime.max_redirects
&& ctx.num_out_messages < ctx.runtime.max_out_messages
&& ctx.message.parts[0]
.headers
.iter()
.filter(|h| matches!(&h.name, HeaderName::Received))
.count()
< ctx.runtime.max_received_headers
{
// Try to avoid forwarding loops
if !self.list && address.eq_ignore_ascii_case(ctx.user_address.as_ref()) {
return;
}
if !self.copy && matches!(&ctx.final_event, Some(Event::Keep { .. })) {
ctx.final_event = None;
}
let mut events = Vec::with_capacity(2);
if let Some(event) = ctx.build_message_id() {
events.push(event);
}
ctx.num_redirects += 1;
ctx.num_out_messages += 1;
events.push(Event::SendMessage {
recipient: if !self.list {
Recipient::Address(address)
} else {
Recipient::List(address)
},
notify: self.notify.clone(),
return_of_content: self.return_of_content.clone(),
by_time: match &self.by_time {
ByTime::Relative {
rlimit,
mode,
trace,
} => ByTime::Relative {
rlimit: *rlimit,
mode: mode.clone(),
trace: *trace,
},
ByTime::Absolute {
alimit,
mode,
trace,
} => ByTime::Absolute {
alimit: DateTime::parse_rfc3339(
ctx.eval_value(alimit).to_string().as_ref(),
)
.and_then(|d| {
if d.is_valid() {
d.to_timestamp().into()
} else {
None
}
})
.unwrap_or(0),
mode: mode.clone(),
trace: *trace,
},
ByTime::None => ByTime::None,
},
message_id: ctx.main_message_id,
});
ctx.queued_events = events.into_iter();
}
}
}
pub(crate) fn sanitize_address(addr: &str) -> Option<String> {
let mut result = String::with_capacity(addr.len());
let mut in_quote = false;
let mut last_ch = '\n';
let mut has_at = false;
let mut has_dot = false;
for ch in addr.chars() {
match ch {
'\"' => {
if !in_quote {
in_quote = true;
} else if last_ch != '\\' {
in_quote = false;
}
}
'@' if !in_quote => {
if !has_at && !result.is_empty() {
has_at = true;
result.push(ch);
} else {
return None;
}
}
'.' if !in_quote && has_at && !has_dot => {
has_dot = true;
result.push(ch);
}
'<' => {
result.clear();
has_at = false;
has_dot = false;
}
'>' => (),
_ => {
if !ch.is_ascii_whitespace() || in_quote {
result.push(ch);
}
}
}
last_ch = ch;
}
if !result.is_empty() && has_at && has_dot {
Some(result)
} else {
None
}
}
+199
View File
@@ -0,0 +1,199 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
Context, Envelope, Event,
compiler::{
VariableType,
grammar::actions::action_set::{Modifier, Set},
},
runtime::Variable,
};
use std::fmt::Write;
impl Set {
pub(crate) fn exec(&self, ctx: &mut Context) {
let mut value = ctx.eval_value(&self.value);
for modifier in &self.modifiers {
value = modifier.apply(value.to_string().as_ref(), ctx).into();
}
ctx.set_variable(&self.name, value);
}
}
impl Context<'_> {
pub(crate) fn set_variable(&mut self, var_name: &VariableType, mut variable: Variable) {
if variable.len() > self.runtime.max_variable_size {
let mut new_variable = String::with_capacity(self.runtime.max_variable_size);
for ch in variable.to_string().chars() {
if ch.len_utf8() + new_variable.len() <= self.runtime.max_variable_size {
new_variable.push(ch);
} else {
break;
}
}
variable = new_variable.into();
}
match var_name {
VariableType::Local(var_id) => {
if let Some(var) = self.vars_local.get_mut(*var_id) {
*var = variable.clone();
} else {
debug_assert!(false, "Non-existent local variable {var_id}");
}
}
VariableType::Global(var_name) => {
self.vars_global
.insert(var_name.to_string().into(), variable.clone());
}
VariableType::Envelope(env) => {
self.add_set_envelope_event(*env, variable.to_string().into_owned());
}
_ => (),
}
}
pub(crate) fn add_set_envelope_event(&mut self, envelope: Envelope, value: String) {
let mut did_find = false;
for (name, val) in self.envelope.iter_mut() {
if *name == envelope {
*val = Variable::String(value.clone().into());
did_find = true;
break;
}
}
if !did_find {
self.envelope
.push((envelope, Variable::String(value.clone().into())));
}
self.queued_events = vec![Event::SetEnvelope { envelope, value }].into_iter();
}
pub(crate) fn get_variable(&self, var_name: &VariableType) -> Option<&Variable> {
match var_name {
VariableType::Local(var_id) => self.vars_local.get(*var_id),
VariableType::Global(var_name) => self.vars_global.get(var_name.as_str()),
VariableType::Envelope(env) => self
.envelope
.iter()
.find_map(|(name, val)| if name == env { Some(val) } else { None }),
_ => unreachable!(),
}
}
}
impl Modifier {
pub(crate) fn apply(&self, input: &str, ctx: &Context) -> String {
let max_len = ctx.runtime.max_variable_size;
match self {
Modifier::Lower => input.to_lowercase(),
Modifier::Upper => input.to_uppercase(),
Modifier::LowerFirst => {
let mut result = String::with_capacity(input.len());
for (pos, char) in input.chars().enumerate() {
if result.len() + char.len_utf8() <= max_len {
if pos != 0 {
result.push(char);
} else {
for char in char.to_lowercase() {
result.push(char);
}
}
} else {
return result;
}
}
result
}
Modifier::UpperFirst => {
let mut result = String::with_capacity(input.len());
for (pos, char) in input.chars().enumerate() {
if result.len() + char.len_utf8() <= max_len {
if pos != 0 {
result.push(char);
} else {
for char in char.to_uppercase() {
result.push(char);
}
}
} else {
return result;
}
}
result
}
Modifier::QuoteWildcard => {
let mut result = String::with_capacity(input.len());
for char in input.chars() {
if ['*', '\\', '?'].contains(&char) {
if result.len() + char.len_utf8() < max_len {
result.push('\\');
result.push(char);
} else {
return result;
}
} else if result.len() + char.len_utf8() <= max_len {
result.push(char);
} else {
return result;
}
}
result
}
Modifier::QuoteRegex => {
let mut result = String::with_capacity(input.len());
for char in input.chars() {
if [
'*', '\\', '?', '.', '[', ']', '(', ')', '+', '{', '}', '|', '^', '=', ':',
'$',
]
.contains(&char)
{
if result.len() + char.len_utf8() < max_len {
result.push('\\');
result.push(char);
} else {
return result;
}
} else if result.len() + char.len_utf8() <= max_len {
result.push(char);
} else {
return result;
}
}
result
}
Modifier::Length => input.chars().count().to_string(),
Modifier::EncodeUrl => {
let mut buf = [0; 4];
let mut result = String::with_capacity(input.len());
for char in input.chars() {
if char.is_ascii_alphanumeric() || ['-', '.', '_', '~'].contains(&char) {
if result.len() < max_len {
result.push(char);
} else {
return result;
}
} else if result.len() + (char.len_utf8() * 3) <= max_len {
for byte in char.encode_utf8(&mut buf).as_bytes().iter() {
write!(result, "%{byte:02x}").ok();
}
} else {
return result;
}
}
result
}
Modifier::Replace { find, replace } => input.replace(
ctx.eval_value(find).to_string().as_ref(),
ctx.eval_value(replace).to_string().as_ref(),
),
}
}
}
+336
View File
@@ -0,0 +1,336 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use std::borrow::Cow;
use mail_builder::headers::{date::Date, message_id::generate_message_id_header};
use mail_parser::{HeaderName, HeaderValue};
use crate::{
Context, Envelope, Event, Recipient,
compiler::grammar::{
AddressPart,
actions::{
action_redirect::{ByTime, Notify, Ret},
action_vacation::{Period, TestVacation, Vacation},
},
},
runtime::tests::TestResult,
};
pub(crate) const MAX_SUBJECT_LEN: usize = 256;
impl TestVacation {
pub(crate) fn exec(&self, ctx: &mut Context) -> TestResult {
let mut from = String::new();
let mut user_addresses = Vec::new();
if ctx.num_out_messages >= ctx.runtime.max_out_messages {
return TestResult::Bool(false);
}
for (name, value) in &ctx.envelope {
if !value.is_empty() {
match name {
Envelope::From => {
from = value.to_string().to_ascii_lowercase();
}
Envelope::To if !ctx.runtime.vacation_use_orig_rcpt => {
user_addresses.push(value.to_string());
}
Envelope::Orcpt if ctx.runtime.vacation_use_orig_rcpt => {
user_addresses.push(value.to_string());
}
_ => (),
}
}
}
// Add user specified addresses
for address in &self.addresses {
let address = ctx.eval_value(address).to_string().into_owned();
if !address.is_empty() {
user_addresses.push(address.into());
}
}
if !ctx.user_address.is_empty() {
user_addresses.push(ctx.user_address.as_ref().into());
}
// Do not reply to own address
if from.is_empty()
|| user_addresses.is_empty()
|| from.starts_with("mailer-daemon")
|| from.starts_with("owner-")
|| from.contains("-request@")
|| user_addresses.iter().any(|a| a.eq_ignore_ascii_case(&from))
{
return TestResult::Bool(false);
}
// Check headers
let mut found_rcpt = false;
let mut received_count = 0;
for header in &ctx.message.parts[0].headers {
match &header.name {
HeaderName::To
| HeaderName::Cc
| HeaderName::Bcc
| HeaderName::ResentTo
| HeaderName::ResentBcc
| HeaderName::ResentCc
if !found_rcpt =>
{
found_rcpt = ctx.find_addresses(header, &AddressPart::All, |addr| {
user_addresses.iter().any(|a| a.eq_ignore_ascii_case(addr))
});
}
HeaderName::ListArchive
| HeaderName::ListHelp
| HeaderName::ListId
| HeaderName::ListOwner
| HeaderName::ListPost
| HeaderName::ListSubscribe
| HeaderName::ListUnsubscribe => {
// Do not send vacation responses to lists
return TestResult::Bool(false);
}
HeaderName::Received => {
received_count += 1;
}
HeaderName::Other(header_name) => {
if header_name.eq_ignore_ascii_case("Auto-Submitted") {
if header
.value
.as_text()
.is_none_or(|v| !v.eq_ignore_ascii_case("no"))
{
return TestResult::Bool(false);
}
} else if header_name.eq_ignore_ascii_case("X-Auto-Response-Suppress") {
if header.value.as_text().is_some_and(|v| {
v.to_ascii_lowercase()
.split(',')
.any(|v| ["all", "oof"].contains(&v.trim()))
}) {
return TestResult::Bool(false);
}
} else if header_name.eq_ignore_ascii_case("Precedence")
&& header
.value
.as_text()
.is_some_and(|v| v.eq_ignore_ascii_case("bulk"))
{
return TestResult::Bool(false);
}
}
_ => (),
}
}
// No user address found in header or possible loop
if found_rcpt && received_count <= ctx.runtime.max_received_headers {
TestResult::Event {
event: Event::DuplicateId {
id: if let Some(handle) = &self.handle {
format!("_v{}{}", from, ctx.eval_value(handle).to_string())
} else {
format!("_v{}{}", from, ctx.eval_value(&self.reason).to_string())
},
expiry: match &self.period {
Period::Days(days) => days * 86400,
Period::Seconds(seconds) => *seconds,
Period::Default => ctx.runtime.default_vacation_expiry,
},
last: false,
},
is_not: true,
}
} else {
TestResult::Bool(false)
}
}
}
impl Vacation {
pub(crate) fn exec(&self, ctx: &mut Context) {
let mut vacation_to = Cow::from("");
for (name, value) in &ctx.envelope {
if !value.is_empty() && name == &Envelope::From {
vacation_to = value.to_string();
break;
}
}
// Check headers
let mut vacation_subject = if let Some(subject) = &self.subject {
ctx.eval_value(subject)
} else {
"".into()
};
// Check headers
let mut message_id = None;
let mut vacation_to_full = None;
let mut references = None;
for header in &ctx.message.parts[0].headers {
match &header.name {
HeaderName::Subject if vacation_subject.is_empty() => {
if let Some(subject) = header.value.as_text() {
let mut vacation_subject_ = String::with_capacity(MAX_SUBJECT_LEN);
let mut iter = ctx
.runtime
.vacation_subject_prefix
.chars()
.chain(subject.chars())
.enumerate();
#[allow(clippy::while_let_on_iterator)]
while let Some((pos, char)) = iter.next() {
if pos < MAX_SUBJECT_LEN {
vacation_subject_.push(char);
} else {
break;
}
}
if iter.next().is_some() {
vacation_subject_.push('…');
}
vacation_subject = vacation_subject_.into();
}
}
HeaderName::MessageId => {
message_id = header.value.as_text();
}
HeaderName::References if header.offset_start > 0 => {
references = (&ctx.message.raw_message
[header.offset_start as usize..header.offset_end as usize])
.into();
}
HeaderName::From | HeaderName::Sender
if matches!(&header.value, HeaderValue::Address(address) if address.contains(vacation_to.as_ref()))
&& header.offset_start > 0 =>
{
vacation_to_full = (&ctx.message.raw_message
[header.offset_start as usize..header.offset_end as usize])
.into();
}
_ => (),
}
}
// Build message
let vacation_from = if let Some(from) = &self.from {
ctx.eval_value(from)
} else if !ctx.user_address.is_empty() {
ctx.user_from_field().into()
} else if let Some(addr) = ctx
.envelope
.iter()
.find_map(|(n, v)| if n == &Envelope::To { Some(v) } else { None })
{
addr.to_string().into()
} else {
"".into()
};
if vacation_subject.is_empty() {
vacation_subject = ctx.runtime.vacation_default_subject.as_ref().into();
}
let vacation_body = ctx.eval_value(&self.reason);
let message_len = vacation_body.len()
+ vacation_from.len()
+ vacation_to_full
.as_ref()
.map_or(vacation_to.len(), |t| t.len())
+ vacation_subject.len()
+ message_id.as_ref().map_or(0, |m| m.len() * 2)
+ references.as_ref().map_or(0, |m| m.len())
+ 160;
let mut message = Vec::with_capacity(message_len);
write_header(&mut message, "From: ", vacation_from.to_string().as_ref());
if let Some(vacation_to_full) = vacation_to_full {
message.extend_from_slice(b"To:");
message.extend_from_slice(vacation_to_full);
} else {
write_header(&mut message, "To: ", vacation_to.to_string().as_ref());
}
write_header(
&mut message,
"Subject: ",
vacation_subject.to_string().as_ref(),
);
if let Some(message_id) = message_id {
message.extend_from_slice(b"In-Reply-To: <");
message.extend_from_slice(message_id.as_bytes());
message.extend_from_slice(b">\r\n");
message.extend_from_slice(b"References: <");
message.extend_from_slice(message_id.as_bytes());
if let Some(references) = references {
message.extend_from_slice(b"> ");
message.extend_from_slice(references);
} else {
message.extend_from_slice(b">\r\n");
}
}
message.extend_from_slice(b"Date: ");
message.extend_from_slice(Date::now().to_rfc822().as_bytes());
message.extend_from_slice(b"\r\n");
message.extend_from_slice(b"Message-ID: ");
generate_message_id_header(&mut message, &ctx.runtime.local_hostname).unwrap();
message.extend_from_slice(b"\r\n");
write_header(&mut message, "Auto-Submitted: ", "auto-replied");
if !self.mime {
message.extend_from_slice(b"Content-type: text/plain; charset=utf-8\r\n\r\n");
}
message.extend_from_slice(vacation_body.to_string().as_bytes());
// Add action
let mut events = Vec::with_capacity(3);
ctx.last_message_id += 1;
ctx.num_out_messages += 1;
events.push(Event::CreatedMessage {
message_id: ctx.last_message_id,
message,
});
events.push(Event::SendMessage {
recipient: Recipient::Address(vacation_to.to_string()),
notify: Notify::Never,
return_of_content: Ret::Default,
by_time: ByTime::None,
message_id: ctx.last_message_id,
});
// File carbon copy
if let Some(fcc) = &self.fcc {
events.push(Event::FileInto {
folder: ctx.eval_value(&fcc.mailbox).to_string().into_owned(),
flags: ctx.get_local_flags(&fcc.flags),
mailbox_id: fcc
.mailbox_id
.as_ref()
.map(|m| ctx.eval_value(m).to_string().into_owned()),
special_use: fcc
.special_use
.as_ref()
.map(|s| ctx.eval_value(s).to_string().into_owned()),
create: fcc.create,
message_id: ctx.last_message_id,
});
}
ctx.queued_events = events.into_iter();
}
}
fn write_header(buf: &mut Vec<u8>, name: &str, value: &str) {
buf.extend_from_slice(name.as_bytes());
buf.extend_from_slice(value.as_bytes());
buf.extend_from_slice(b"\r\n");
}
+16
View File
@@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
pub mod action_convert;
pub mod action_editheader;
pub mod action_fileinto;
pub mod action_flags;
pub mod action_include;
pub mod action_mime;
pub mod action_notify;
pub mod action_redirect;
pub mod action_set;
pub mod action_vacation;