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:
Vendored
+482
@@ -0,0 +1,482 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use std::cmp::Ordering;
|
||||
|
||||
use mail_parser::{
|
||||
Addr, Header, HeaderName, HeaderValue, Host, PartType, Received,
|
||||
decoders::html::{html_to_text, text_to_html},
|
||||
parsers::MessageStream,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
Context,
|
||||
compiler::{
|
||||
ContentTypePart, HeaderPart, HeaderVariable, MessagePart, ReceivedHostname, ReceivedPart,
|
||||
Value, VariableType,
|
||||
},
|
||||
};
|
||||
|
||||
use super::Variable;
|
||||
|
||||
impl<'x> Context<'x> {
|
||||
pub(crate) fn variable<'y: 'x>(&'y self, var: &VariableType) -> Option<Variable> {
|
||||
match var {
|
||||
VariableType::Local(var_num) => self.vars_local.get(*var_num).cloned(),
|
||||
VariableType::Match(var_num) => self.vars_match.get(*var_num).cloned(),
|
||||
VariableType::Global(var_name) => self.vars_global.get(var_name.as_str()).cloned(),
|
||||
VariableType::Environment(var_name) => self
|
||||
.vars_env
|
||||
.get(var_name.as_str())
|
||||
.or_else(|| self.runtime.environment.get(var_name.as_str()))
|
||||
.cloned(),
|
||||
VariableType::Envelope(envelope) => self
|
||||
.envelope
|
||||
.iter()
|
||||
.find_map(|(e, v)| if e == envelope { Some(v.clone()) } else { None }),
|
||||
VariableType::Header(header) => self.eval_header(header),
|
||||
VariableType::Part(part) => match part {
|
||||
MessagePart::TextBody(convert) => {
|
||||
let part = self
|
||||
.message
|
||||
.parts
|
||||
.get(*self.message.text_body.first()? as usize)?;
|
||||
match &part.body {
|
||||
PartType::Text(text) => Some(text.as_ref().into()),
|
||||
PartType::Html(html) if *convert => {
|
||||
Some(html_to_text(html.as_ref()).into())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
MessagePart::HtmlBody(convert) => {
|
||||
let part = self
|
||||
.message
|
||||
.parts
|
||||
.get(*self.message.html_body.first()? as usize)?;
|
||||
match &part.body {
|
||||
PartType::Html(html) => Some(html.as_ref().into()),
|
||||
PartType::Text(text) if *convert => {
|
||||
Some(text_to_html(text.as_ref()).into())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
MessagePart::Contents => match &self.message.parts.get(self.part as usize)?.body {
|
||||
PartType::Text(text) | PartType::Html(text) => {
|
||||
Variable::from(text.as_ref()).into()
|
||||
}
|
||||
PartType::Binary(bin) | PartType::InlineBinary(bin) => {
|
||||
Variable::from(String::from_utf8_lossy(bin.as_ref())).into()
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
MessagePart::Raw => {
|
||||
let part = self.message.parts.get(self.part as usize)?;
|
||||
self.message
|
||||
.raw_message()
|
||||
.get(part.raw_body_offset() as usize..part.raw_end_offset() as usize)
|
||||
.map(|v| Variable::from(String::from_utf8_lossy(v)))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn eval_value(&self, string: &Value) -> Variable {
|
||||
match string {
|
||||
Value::Text(text) => Variable::String(text.clone()),
|
||||
Value::Variable(var) => self.variable(var).unwrap_or_default(),
|
||||
Value::List(list) => {
|
||||
let mut data = String::new();
|
||||
for item in list {
|
||||
match item {
|
||||
Value::Text(string) => {
|
||||
data.push_str(string);
|
||||
}
|
||||
Value::Variable(var) => {
|
||||
if let Some(value) = self.variable(var) {
|
||||
data.push_str(&value.to_string());
|
||||
}
|
||||
}
|
||||
Value::List(_) => {
|
||||
debug_assert!(false, "This should not have happened: {string:?}");
|
||||
}
|
||||
Value::Number(n) => {
|
||||
data.push_str(&n.to_string());
|
||||
}
|
||||
Value::Regex(_) => (),
|
||||
}
|
||||
}
|
||||
data.into()
|
||||
}
|
||||
Value::Number(n) => Variable::from(*n),
|
||||
Value::Regex(r) => Variable::String(r.expr.clone().into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn eval_header<'z: 'x>(&'z self, header: &HeaderVariable) -> Option<Variable> {
|
||||
let mut result = Vec::new();
|
||||
let part = self.message.part(self.part)?;
|
||||
let raw = self.message.raw_message();
|
||||
if !header.name.is_empty() {
|
||||
let mut headers = part
|
||||
.headers
|
||||
.iter()
|
||||
.filter(|h| header.name.contains(&h.name));
|
||||
match header.index_hdr.cmp(&0) {
|
||||
Ordering::Greater => {
|
||||
if let Some(h) = headers.nth((header.index_hdr - 1) as usize) {
|
||||
header.eval_part(h, raw, &mut result);
|
||||
}
|
||||
}
|
||||
Ordering::Less => {
|
||||
if let Some(h) = headers
|
||||
.rev()
|
||||
.nth((header.index_hdr.unsigned_abs() - 1) as usize)
|
||||
{
|
||||
header.eval_part(h, raw, &mut result);
|
||||
}
|
||||
}
|
||||
Ordering::Equal => {
|
||||
for h in headers {
|
||||
header.eval_part(h, raw, &mut result);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for h in &part.headers {
|
||||
match &header.part {
|
||||
HeaderPart::Raw => {
|
||||
if let Some(var) = raw
|
||||
.get(h.offset_field as usize..h.offset_end as usize)
|
||||
.map(sanitize_raw_header)
|
||||
{
|
||||
result.push(Variable::from(var));
|
||||
}
|
||||
}
|
||||
HeaderPart::Text => {
|
||||
if let HeaderValue::Text(text) = &h.value {
|
||||
result.push(Variable::from(format!("{}: {}", h.name.as_str(), text)));
|
||||
} else if let HeaderValue::Text(text) = MessageStream::new(
|
||||
raw.get(h.offset_start as usize..h.offset_end as usize)
|
||||
.unwrap_or(b""),
|
||||
)
|
||||
.parse_unstructured()
|
||||
{
|
||||
result.push(Variable::from(format!("{}: {}", h.name.as_str(), text)));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
header.eval_part(h, raw, &mut result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match result.len() {
|
||||
1 if header.index_hdr != 0 && header.index_part != 0 => result.pop(),
|
||||
0 => None,
|
||||
_ => Some(Variable::Array(result.into())),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub(crate) fn eval_values<'z: 'y, 'y>(&'z self, strings: &'y [Value]) -> Vec<Variable> {
|
||||
strings.iter().map(|s| self.eval_value(s)).collect()
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub(crate) fn eval_values_owned(&self, strings: &[Value]) -> Vec<String> {
|
||||
strings
|
||||
.iter()
|
||||
.map(|s| self.eval_value(s).to_string().into_owned())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> HeaderVariable<'x> {
|
||||
fn eval_part(&self, header: &'x Header<'x>, raw: &'x [u8], result: &mut Vec<Variable>) {
|
||||
let var = match &self.part {
|
||||
HeaderPart::Text => match &header.value {
|
||||
HeaderValue::Text(v) if self.include_single_part() => {
|
||||
Some(Variable::from(v.as_ref()))
|
||||
}
|
||||
HeaderValue::TextList(list) => match self.index_part.cmp(&0) {
|
||||
Ordering::Greater => list
|
||||
.get((self.index_part - 1) as usize)
|
||||
.map(|v| Variable::from(v.as_ref())),
|
||||
Ordering::Less => list
|
||||
.iter()
|
||||
.rev()
|
||||
.nth((self.index_part.unsigned_abs() - 1) as usize)
|
||||
.map(|v| Variable::from(v.as_ref())),
|
||||
Ordering::Equal => {
|
||||
for item in list {
|
||||
result.push(Variable::from(item.as_ref()));
|
||||
}
|
||||
return;
|
||||
}
|
||||
},
|
||||
HeaderValue::ContentType(ct) => if let Some(st) = &ct.c_subtype {
|
||||
Variable::from(format!("{}/{}", ct.c_type, st))
|
||||
} else {
|
||||
Variable::from(ct.c_type.as_ref())
|
||||
}
|
||||
.into(),
|
||||
HeaderValue::Address(list) => {
|
||||
let mut list = list.iter();
|
||||
match self.index_part.cmp(&0) {
|
||||
Ordering::Greater => list
|
||||
.nth((self.index_part - 1) as usize)
|
||||
.map(|a| a.to_text()),
|
||||
Ordering::Less => list
|
||||
.rev()
|
||||
.nth((self.index_part.unsigned_abs() - 1) as usize)
|
||||
.map(|a| a.to_text()),
|
||||
Ordering::Equal => {
|
||||
for item in list {
|
||||
result.push(item.to_text());
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
HeaderValue::DateTime(_) => raw
|
||||
.get(header.offset_start as usize..header.offset_end as usize)
|
||||
.and_then(|bytes| std::str::from_utf8(bytes).ok())
|
||||
.map(|s| s.trim())
|
||||
.map(Variable::from),
|
||||
_ => None,
|
||||
},
|
||||
HeaderPart::Address(part) => match &header.value {
|
||||
HeaderValue::Address(addr) => {
|
||||
let mut list = addr.iter();
|
||||
match self.index_part.cmp(&0) {
|
||||
Ordering::Greater => list
|
||||
.nth((self.index_part - 1) as usize)
|
||||
.and_then(|a| part.eval_strict(a))
|
||||
.map(Variable::from),
|
||||
Ordering::Less => list
|
||||
.rev()
|
||||
.nth((self.index_part.unsigned_abs() - 1) as usize)
|
||||
.and_then(|a| part.eval_strict(a))
|
||||
.map(Variable::from),
|
||||
Ordering::Equal => {
|
||||
for item in list {
|
||||
result.push(
|
||||
part.eval_strict(item)
|
||||
.map(Variable::from)
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
HeaderValue::Text(_) => {
|
||||
let addr = raw
|
||||
.get(header.offset_start as usize..header.offset_end as usize)
|
||||
.and_then(|bytes| match MessageStream::new(bytes).parse_address() {
|
||||
HeaderValue::Address(addr) => addr.into(),
|
||||
_ => None,
|
||||
});
|
||||
if let Some(addr) = addr {
|
||||
let mut list = addr.iter();
|
||||
match self.index_part.cmp(&0) {
|
||||
Ordering::Greater => list
|
||||
.nth((self.index_part - 1) as usize)
|
||||
.and_then(|a| part.eval_strict(a))
|
||||
.map(|s| Variable::String(s.to_string().into())),
|
||||
Ordering::Less => list
|
||||
.rev()
|
||||
.nth((self.index_part.unsigned_abs() - 1) as usize)
|
||||
.and_then(|a| part.eval_strict(a))
|
||||
.map(|s| Variable::String(s.to_string().into())),
|
||||
Ordering::Equal => {
|
||||
for item in list {
|
||||
result.push(
|
||||
part.eval_strict(item)
|
||||
.map(|s| Variable::String(s.to_string().into()))
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
HeaderPart::Date => {
|
||||
if let HeaderValue::DateTime(dt) = &header.value {
|
||||
Variable::from(dt.to_timestamp()).into()
|
||||
} else {
|
||||
raw.get(header.offset_start as usize..header.offset_end as usize)
|
||||
.and_then(|bytes| match MessageStream::new(bytes).parse_date() {
|
||||
HeaderValue::DateTime(dt) => Variable::from(dt.to_timestamp()).into(),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
}
|
||||
HeaderPart::Id => match &header.name {
|
||||
HeaderName::MessageId | HeaderName::ResentMessageId => match &header.value {
|
||||
HeaderValue::Text(id) => Variable::from(id.as_ref()).into(),
|
||||
HeaderValue::TextList(ids) => {
|
||||
for id in ids {
|
||||
result.push(Variable::from(id.as_ref()));
|
||||
}
|
||||
return;
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
HeaderName::Other(_) => {
|
||||
match MessageStream::new(
|
||||
raw.get(header.offset_start as usize..header.offset_end as usize)
|
||||
.unwrap_or(b""),
|
||||
)
|
||||
.parse_id()
|
||||
{
|
||||
HeaderValue::Text(id) => Variable::from(id).into(),
|
||||
HeaderValue::TextList(ids) => {
|
||||
for id in ids {
|
||||
result.push(Variable::from(id));
|
||||
}
|
||||
return;
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
|
||||
HeaderPart::Raw => raw
|
||||
.get(header.offset_start as usize..header.offset_end as usize)
|
||||
.map(sanitize_raw_header)
|
||||
.map(Variable::from),
|
||||
HeaderPart::RawName => raw
|
||||
.get(header.offset_field as usize..header.offset_start as usize - 1)
|
||||
.map(|bytes| std::str::from_utf8(bytes).unwrap_or_default())
|
||||
.map(Variable::from),
|
||||
HeaderPart::Exists => Variable::from(true).into(),
|
||||
_ => match (&header.value, &self.part) {
|
||||
(HeaderValue::ContentType(ct), HeaderPart::ContentType(part)) => match part {
|
||||
ContentTypePart::Type => Variable::from(ct.c_type.as_ref()).into(),
|
||||
ContentTypePart::Subtype => {
|
||||
ct.c_subtype.as_ref().map(|s| Variable::from(s.as_ref()))
|
||||
}
|
||||
ContentTypePart::Attribute(attr) => ct.attributes.as_ref().and_then(|attrs| {
|
||||
attrs.iter().find_map(|a| {
|
||||
if a.name.eq_ignore_ascii_case(attr) {
|
||||
Some(Variable::from(a.value.as_ref()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}),
|
||||
},
|
||||
(HeaderValue::Received(rcvd), HeaderPart::Received(part)) => part.eval(rcvd),
|
||||
_ => None,
|
||||
},
|
||||
};
|
||||
|
||||
result.push(var.unwrap_or_default());
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn include_single_part(&self) -> bool {
|
||||
[-1, 0, 1].contains(&self.index_part)
|
||||
}
|
||||
}
|
||||
|
||||
impl ReceivedPart {
|
||||
pub fn eval<'x>(&self, rcvd: &'x Received<'x>) -> Option<Variable> {
|
||||
match self {
|
||||
ReceivedPart::From(from) => rcvd
|
||||
.from()
|
||||
.or_else(|| rcvd.helo())
|
||||
.and_then(|v| from.to_variable(v)),
|
||||
ReceivedPart::FromIp => rcvd.from_ip().map(|ip| Variable::from(ip.to_string())),
|
||||
ReceivedPart::FromIpRev => rcvd.from_iprev().map(Variable::from),
|
||||
ReceivedPart::By(by) => rcvd.by().and_then(|v: &Host<'_>| by.to_variable(v)),
|
||||
ReceivedPart::For => rcvd.for_().map(Variable::from),
|
||||
ReceivedPart::With => rcvd.with().map(|v| Variable::from(v.as_str())),
|
||||
ReceivedPart::TlsVersion => rcvd.tls_version().map(|v| Variable::from(v.as_str())),
|
||||
ReceivedPart::TlsCipher => rcvd.tls_cipher().map(Variable::from),
|
||||
ReceivedPart::Id => rcvd.id().map(Variable::from),
|
||||
ReceivedPart::Ident => rcvd.ident().map(Variable::from),
|
||||
ReceivedPart::Via => rcvd.via().map(Variable::from),
|
||||
ReceivedPart::Date => rcvd.date().map(|d| Variable::from(d.to_timestamp())),
|
||||
ReceivedPart::DateRaw => rcvd.date().map(|d| Variable::from(d.to_rfc822())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trait AddrToText<'x> {
|
||||
fn to_text<'z: 'x>(&'z self) -> Variable;
|
||||
}
|
||||
|
||||
impl<'x> AddrToText<'x> for Addr<'x> {
|
||||
fn to_text<'z: 'x>(&'z self) -> Variable {
|
||||
if let Some(name) = &self.name {
|
||||
if let Some(address) = &self.address {
|
||||
Variable::String(format!("{name} <{address}>").into())
|
||||
} else {
|
||||
Variable::String(name.to_string().into())
|
||||
}
|
||||
} else if let Some(address) = &self.address {
|
||||
Variable::String(format!("<{address}>").into())
|
||||
} else {
|
||||
Variable::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ReceivedHostname {
|
||||
fn to_variable<'x>(&self, host: &'x Host<'x>) -> Option<Variable> {
|
||||
match (self, host) {
|
||||
(ReceivedHostname::Name, Host::Name(name)) => Variable::from(name.as_ref()).into(),
|
||||
(ReceivedHostname::Ip, Host::IpAddr(ip)) => Variable::from(ip.to_string()).into(),
|
||||
(ReceivedHostname::Any, _) => Variable::from(host.to_string()).into(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) trait IntoString: Sized {
|
||||
fn into_string(self) -> String;
|
||||
}
|
||||
|
||||
pub(crate) trait ToString: Sized {
|
||||
fn to_string(&self) -> String;
|
||||
}
|
||||
|
||||
impl IntoString for Vec<u8> {
|
||||
fn into_string(self) -> String {
|
||||
String::from_utf8(self)
|
||||
.unwrap_or_else(|err| String::from_utf8_lossy(err.as_bytes()).into_owned())
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_raw_header(bytes: &[u8]) -> String {
|
||||
let mut result = Vec::with_capacity(bytes.len());
|
||||
let mut last_is_space = false;
|
||||
|
||||
for &ch in bytes {
|
||||
if ch.is_ascii_whitespace() {
|
||||
last_is_space = true;
|
||||
} else {
|
||||
if last_is_space {
|
||||
result.push(b' ');
|
||||
last_is_space = false;
|
||||
}
|
||||
result.push(ch);
|
||||
}
|
||||
}
|
||||
|
||||
result.into_string()
|
||||
}
|
||||
Reference in New Issue
Block a user