Rename the identifiers that carried the upstream name
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(
|
||||
¤t_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(
|
||||
¤t_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(
|
||||
¤t_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(
|
||||
¤t_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(
|
||||
¤t_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));
|
||||
}
|
||||
@@ -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: ", ¶ms.to), ("Cc: ", ¶ms.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 ¶ms.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) = ¬ify_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) = ¬ify_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);
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
Reference in New Issue
Block a user