Installation binary first part.

This commit is contained in:
mdecimus 2023-07-09 11:06:37 +02:00
parent 705762c312
commit 843e61139a
27 changed files with 848 additions and 142 deletions

44
Cargo.lock generated
View file

@ -987,6 +987,18 @@ dependencies = [
"rusticata-macros", "rusticata-macros",
] ]
[[package]]
name = "dialoguer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59c6f2989294b9a498d3ad5491a79c6deb604617378e1cdc4bfc1c1361fe2f87"
dependencies = [
"console",
"shell-words",
"tempfile",
"zeroize",
]
[[package]] [[package]]
name = "digest" name = "digest"
version = "0.9.0" version = "0.9.0"
@ -2722,6 +2734,15 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
[[package]]
name = "openssl-src"
version = "111.26.0+1.1.1u"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "efc62c9f12b22b8f5208c23a7200a442b2e5999f8bdf80233852122b5a4f6f37"
dependencies = [
"cc",
]
[[package]] [[package]]
name = "openssl-sys" name = "openssl-sys"
version = "0.9.90" version = "0.9.90"
@ -2730,6 +2751,7 @@ checksum = "374533b0e45f3a7ced10fcaeccca020e66656bc03dac384f852e4e5a7a8104a6"
dependencies = [ dependencies = [
"cc", "cc",
"libc", "libc",
"openssl-src",
"pkg-config", "pkg-config",
"vcpkg", "vcpkg",
] ]
@ -3971,6 +3993,12 @@ dependencies = [
"lazy_static", "lazy_static",
] ]
[[package]]
name = "shell-words"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde"
[[package]] [[package]]
name = "shlex" name = "shlex"
version = "1.1.0" version = "1.1.0"
@ -4352,6 +4380,22 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "stalwart-install"
version = "0.3.0"
dependencies = [
"base64 0.21.2",
"dialoguer",
"indicatif",
"libc",
"openssl",
"pwhash",
"rand",
"reqwest",
"rpassword",
"rusqlite",
]
[[package]] [[package]]
name = "static_assertions" name = "static_assertions"
version = "1.1.0" version = "1.1.0"

View file

@ -13,6 +13,7 @@ members = [
"crates/utils", "crates/utils",
"crates/maybe-async", "crates/maybe-async",
"crates/cli", "crates/cli",
"crates/install",
"tests", "tests",
] ]

View file

@ -42,7 +42,8 @@ impl ConfigDirectory for Config {
}; };
// Add queries/filters as lookups // Add queries/filters as lookups
if ["sql", "ldap"].contains(&protocol) { let is_directory = ["sql", "ldap"].contains(&protocol);
if is_directory {
let name = if protocol == "sql" { "query" } else { "filter" }; let name = if protocol == "sql" { "query" } else { "filter" };
for lookup_id in self.sub_keys(("directory", id, name)) { for lookup_id in self.sub_keys(("directory", id, name)) {
config.lookups.insert( config.lookups.insert(
@ -58,9 +59,8 @@ impl ConfigDirectory for Config {
} }
// Parse lookups // Parse lookups
let is_remote = protocol != "memory";
for lookup_id in self.sub_keys(("directory", id, "lookup")) { for lookup_id in self.sub_keys(("directory", id, "lookup")) {
let lookup = if is_remote { let lookup = if is_directory {
Lookup::Directory { Lookup::Directory {
directory: directory.clone(), directory: directory.clone(),
query: self query: self
@ -120,6 +120,10 @@ impl DirectoryOptions {
Ok(DirectoryOptions { Ok(DirectoryOptions {
catch_all: config.property_or_static((&key, "options.catch-all"), "false")?, catch_all: config.property_or_static((&key, "options.catch-all"), "false")?,
subaddressing: config.property_or_static((&key, "options.subaddressing"), "true")?, subaddressing: config.property_or_static((&key, "options.subaddressing"), "true")?,
superuser_group: config
.value("options.superuser-group")
.unwrap_or("superusers")
.to_string(),
}) })
} }
} }

View file

@ -21,7 +21,7 @@ impl ImapDirectory {
let address = config.value_require((&prefix, "address"))?; let address = config.value_require((&prefix, "address"))?;
let tls_implicit: bool = config.property_or_static((&prefix, "tls.implicit"), "false")?; let tls_implicit: bool = config.property_or_static((&prefix, "tls.implicit"), "false")?;
let port: u16 = config let port: u16 = config
.property_or_static((&prefix, "port"), if tls_implicit { "443" } else { "143" })?; .property_or_static((&prefix, "port"), if tls_implicit { "993" } else { "143" })?;
let manager = ImapConnectionManager { let manager = ImapConnectionManager {
addr: format!("{address}:{port}"), addr: format!("{address}:{port}"),

View file

@ -304,17 +304,25 @@ impl LdapDirectory {
for entry in rs { for entry in rs {
'outer: for (attr, value) in SearchEntry::construct(entry).attrs { 'outer: for (attr, value) in SearchEntry::construct(entry).attrs {
if self.mappings.attr_name.contains(&attr) { if self.mappings.attr_name.contains(&attr) {
if let Some(name) = value.first() { if let Some(group) = value.first() {
if !name.is_empty() { if !group.is_empty() {
names.push(name.to_string()); if !group
.eq_ignore_ascii_case(&self.opt.superuser_group)
{
names.push(group.to_string());
} else {
principal.typ = Type::Superuser;
}
break 'outer; break 'outer;
} }
} }
} }
} }
} }
} else { } else if !group.eq_ignore_ascii_case(&self.opt.superuser_group) {
names.push(group); names.push(group);
} else {
principal.typ = Type::Superuser;
} }
} }
principal.member_of = names; principal.member_of = names;

View file

@ -33,6 +33,7 @@ pub enum Type {
Location, Location,
#[default] #[default]
Other, Other,
Superuser,
} }
#[derive(Debug)] #[derive(Debug)]
@ -135,7 +136,7 @@ impl Debug for Lookup {
impl Type { impl Type {
pub fn to_jmap(&self) -> &'static str { pub fn to_jmap(&self) -> &'static str {
match self { match self {
Self::Individual => "individual", Self::Individual | Self::Superuser => "individual",
Self::Group => "group", Self::Group => "group",
Self::Resource => "resource", Self::Resource => "resource",
Self::Location => "location", Self::Location => "location",
@ -148,6 +149,7 @@ impl Type {
struct DirectoryOptions { struct DirectoryOptions {
catch_all: bool, catch_all: bool,
subaddressing: bool, subaddressing: bool,
superuser_group: String,
} }
#[derive(Default, Clone, Debug)] #[derive(Default, Clone, Debug)]

View file

@ -12,12 +12,26 @@ impl MemoryDirectory {
prefix: impl AsKey, prefix: impl AsKey,
) -> utils::config::Result<Arc<dyn Directory>> { ) -> utils::config::Result<Arc<dyn Directory>> {
let prefix = prefix.as_key(); let prefix = prefix.as_key();
let mut directory = MemoryDirectory::default(); let mut directory = MemoryDirectory {
opt: DirectoryOptions::from_config(config, prefix.clone())?,
..Default::default()
};
for lookup_id in config.sub_keys((prefix.as_str(), "users")) { for lookup_id in config.sub_keys((prefix.as_str(), "users")) {
let name = config let name = config
.value_require((prefix.as_str(), "users", lookup_id, "name"))? .value_require((prefix.as_str(), "users", lookup_id, "name"))?
.to_string(); .to_string();
let mut typ = Type::Individual;
let mut member_of = Vec::new();
for (_, group) in config.values((prefix.as_str(), "users", lookup_id, "member-of")) {
if !group.eq_ignore_ascii_case(&directory.opt.superuser_group) {
member_of.push(group.to_string());
} else {
typ = Type::Superuser;
}
}
directory.principals.insert( directory.principals.insert(
name.clone(), name.clone(),
Principal { Principal {
@ -26,17 +40,14 @@ impl MemoryDirectory {
.values((prefix.as_str(), "users", lookup_id, "secret")) .values((prefix.as_str(), "users", lookup_id, "secret"))
.map(|(_, v)| v.to_string()) .map(|(_, v)| v.to_string())
.collect(), .collect(),
typ: Type::Individual, typ,
description: config description: config
.value((prefix.as_str(), "users", lookup_id, "description")) .value((prefix.as_str(), "users", lookup_id, "description"))
.map(|v| v.to_string()), .map(|v| v.to_string()),
quota: config quota: config
.property((prefix.as_str(), "users", lookup_id, "quota"))? .property((prefix.as_str(), "users", lookup_id, "quota"))?
.unwrap_or(0), .unwrap_or(0),
member_of: config member_of,
.values((prefix.as_str(), "users", lookup_id, "member-of"))
.map(|(_, v)| v.to_string())
.collect(),
}, },
); );
let mut emails = Vec::new(); let mut emails = Vec::new();
@ -105,7 +116,6 @@ impl MemoryDirectory {
directory directory
.domains .domains
.extend(config.parse_lookup_list((&prefix, "lookup.domains"))?); .extend(config.parse_lookup_list((&prefix, "lookup.domains"))?);
directory.opt = DirectoryOptions::from_config(config, prefix)?;
Ok(Arc::new(directory)) Ok(Arc::new(directory))
} }

View file

@ -39,6 +39,16 @@ impl Directory for SqlDirectory {
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await?; .await?;
// Check whether the user is a superuser
if let Some(idx) = principal
.member_of
.iter()
.position(|group| group.eq_ignore_ascii_case(&self.opt.superuser_group))
{
principal.member_of.swap_remove(idx);
principal.typ = Type::Superuser;
}
Ok(Some(principal)) Ok(Some(principal))
} else { } else {
Ok(None) Ok(None)

View file

@ -5,7 +5,6 @@ use imap_proto::{protocol::list::Attribute, StatusResponse};
use jmap::{ use jmap::{
auth::{acl::EffectiveAcl, AccessToken}, auth::{acl::EffectiveAcl, AccessToken},
mailbox::INBOX_ID, mailbox::INBOX_ID,
SUPERUSER_ID,
}; };
use jmap_proto::{ use jmap_proto::{
object::Object, object::Object,
@ -43,7 +42,6 @@ impl SessionData {
// Fetch shared mailboxes // Fetch shared mailboxes
for &account_id in access_token.shared_accounts(Collection::Mailbox) { for &account_id in access_token.shared_accounts(Collection::Mailbox) {
if account_id != SUPERUSER_ID {
match session match session
.fetch_account_mailboxes( .fetch_account_mailboxes(
account_id, account_id,
@ -70,7 +68,6 @@ impl SessionData {
} }
} }
} }
}
session.mailboxes = Mutex::new(mailboxes); session.mailboxes = Mutex::new(mailboxes);
@ -305,8 +302,7 @@ impl SessionData {
// Add new shared account ids // Add new shared account ids
for account_id in has_access_to { for account_id in has_access_to {
if account_id != SUPERUSER_ID if !new_accounts
&& !new_accounts
.iter() .iter()
.skip(1) .skip(1)
.any(|m| m.account_id == account_id) .any(|m| m.account_id == account_id)

View file

@ -42,14 +42,16 @@ impl IMAP {
}) })
.into_bytes(), .into_bytes(),
rate_limiter: DashMap::with_capacity_and_hasher_and_shard_amount( rate_limiter: DashMap::with_capacity_and_hasher_and_shard_amount(
config.property("imap.rate-limit.size")?.unwrap_or(2048), config
.property("imap.rate-limit.cache.size")?
.unwrap_or(2048),
RandomState::default(), RandomState::default(),
config config
.property::<u64>("global.shared-map.shard")? .property::<u64>("global.shared-map.shard")?
.unwrap_or(32) .unwrap_or(32)
.next_power_of_two() as usize, .next_power_of_two() as usize,
), ),
rate_requests: config.property_or_static("imap.rate-limit", "1000/1m")?, rate_requests: config.property_or_static("imap.rate-limit.requests", "2000/1m")?,
rate_concurrent: config.property("imap.rate-limit.concurrent")?.unwrap_or(4), rate_concurrent: config.property("imap.rate-limit.concurrent")?.unwrap_or(4),
allow_plain_auth: config.property_or_static("imap.auth.allow-plain-text", "false")?, allow_plain_auth: config.property_or_static("imap.auth.allow-plain-text", "false")?,
})) }))

25
crates/install/Cargo.toml Normal file
View file

@ -0,0 +1,25 @@
[package]
name = "stalwart-install"
description = "Stalwart Mail Server installer"
authors = ["Stalwart Labs Ltd. <hello@stalw.art>"]
license = "AGPL-3.0-only"
repository = "https://github.com/stalwartlabs/mail-server"
homepage = "https://github.com/stalwartlabs/mail-server"
version = "0.3.0"
edition = "2021"
readme = "README.md"
resolver = "2"
[dependencies]
reqwest = { version = "0.11", default-features = false, features = ["rustls-tls-webpki-roots"]}
rusqlite = { version = "0.29.0", features = ["bundled"] }
rpassword = "7.0"
indicatif = "0.17.0"
dialoguer = "0.10.4"
openssl = { version = "0.10.55", features = ["vendored"] }
base64 = "0.21.2"
pwhash = "1.0.0"
rand = "0.8.5"
[target.'cfg(not(target_env = "msvc"))'.dependencies]
libc = "0.2.147"

562
crates/install/src/main.rs Normal file
View file

@ -0,0 +1,562 @@
use std::{
fs,
path::{Path, PathBuf},
time::SystemTime,
};
use base64::{engine::general_purpose, Engine};
use dialoguer::{console::Term, theme::ColorfulTheme, Input, Select};
use openssl::rsa::Rsa;
use pwhash::sha512_crypt;
use rand::{distributions::Alphanumeric, thread_rng, Rng};
use rusqlite::{Connection, OpenFlags};
const CFG_COMMON: &str = include_str!("../../../resources/config/common.toml");
const CFG_DIRECTORY: &str = include_str!("../../../resources/config/directory.toml");
const CFG_JMAP: &str = include_str!("../../../resources/config/jmap.toml");
const CFG_IMAP: &str = include_str!("../../../resources/config/imap.toml");
const CFG_SMTP: &str = include_str!("../../../resources/config/smtp.toml");
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Component {
AllInOne,
Jmap,
Imap,
Smtp,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Backend {
SQLite,
FoundationDB,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Blob {
Local,
MinIO,
S3,
GCS,
Azure,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Directory {
SQL,
LDAP,
None,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SmtpDirectory {
SQL,
LDAP,
LMTP,
IMAP,
}
const DIRECTORIES: [[&str; 2]; 6] = [
["bin", ""],
["etc", "dkim"],
["data", "blobs"],
["logs", ""],
["queue", ""],
["reports", ""],
];
fn main() -> std::io::Result<()> {
let c = "fix";
/*#[cfg(not(target_env = "msvc"))]
unsafe {
if libc::getuid() != 0 {
eprintln!("This program must be run as root.");
std::process::exit(1);
}
}*/
println!("\nWelcome to the Stalwart mail server installer\n");
let component = select::<Component>(
"Which components would you like to install?",
&[
"All-in-one mail server (JMAP + IMAP + SMTP)",
"JMAP server",
"IMAP server",
"SMTP server",
],
Component::AllInOne,
)?;
let mut cfg_file = match component {
Component::AllInOne | Component::Imap => {
[CFG_COMMON, CFG_DIRECTORY, CFG_JMAP, CFG_IMAP, CFG_SMTP].join("\n")
}
Component::Jmap => [CFG_COMMON, CFG_DIRECTORY, CFG_JMAP, CFG_SMTP].join("\n"),
Component::Smtp => [CFG_COMMON, CFG_DIRECTORY, CFG_SMTP].join("\n"),
};
let directory = if component != Component::Smtp {
let backend = select::<Backend>(
"Which database engine would you like to use?",
&[
"SQLite (single node, replicated with Litestream)",
"FoundationDB (distributed and fault-tolerant)",
],
Backend::SQLite,
)?;
let blob = select::<Blob>(
"Where would you like to store e-mails and blobs?",
&[
"Local disk using Maildir",
"MinIO (or any S3-compatible object storage)",
"Amazon S3",
"Google Cloud Storage",
"Azure Blob Storage",
],
Blob::Local,
)?;
let directory = select::<Directory>(
"Do you already have a directory or database containing your accounts?",
&[
"Yes, it's an SQL database",
"Yes, it's an LDAP directory",
"No, create a new directory for me",
],
Directory::None,
)?;
cfg_file = cfg_file
.replace(
"__BLOB_STORE__",
match blob {
Blob::Local => "local",
_ => "s3",
},
)
.replace("__NEXT_HOP__", "local")
.replace(
"__DIRECTORY__",
match directory {
Directory::SQL | Directory::None => "sql",
Directory::LDAP => "ldap",
},
)
.replace(
"__SMTP_DIRECTORY__",
match directory {
Directory::SQL | Directory::None => "sql",
Directory::LDAP => "ldap",
},
)
.replace(
"__OAUTH_KEY__",
&thread_rng()
.sample_iter(Alphanumeric)
.take(64)
.map(char::from)
.collect::<String>(),
);
directory
} else {
let smtp_directory = select::<SmtpDirectory>(
"How should your local accounts be validated?",
&[
"SQL database",
"LDAP directory",
"LMTP server",
"IMAP server",
],
SmtpDirectory::LMTP,
)?;
cfg_file = cfg_file
.replace("__NEXT_HOP__", "lmtp")
.replace(
"__SMTP_DIRECTORY__",
match smtp_directory {
SmtpDirectory::SQL => "sql",
SmtpDirectory::LDAP => "ldap",
SmtpDirectory::LMTP => "lmtp",
SmtpDirectory::IMAP => "imap",
},
)
.replace(
"__DIRECTORY__",
match smtp_directory {
SmtpDirectory::SQL | SmtpDirectory::LMTP | SmtpDirectory::IMAP => "sql",
SmtpDirectory::LDAP => "ldap",
},
)
.replace("__NEXT_HOP__", "lmtp");
match smtp_directory {
SmtpDirectory::SQL => Directory::SQL,
SmtpDirectory::LDAP => Directory::LDAP,
SmtpDirectory::LMTP | SmtpDirectory::IMAP => Directory::None,
}
};
let base_path = PathBuf::from(input(
"Installation directory",
component.default_base_path(),
dir_create_if_missing,
)?);
create_directories(&base_path)?;
let domain = input(
"What is your main domain name? (you can add others later)",
"yourdomain.org",
not_empty,
)?
.trim()
.to_lowercase();
let hostname = input(
"What is your server hostname?",
&format!("mail.{domain}"),
not_empty,
)?
.trim()
.to_lowercase();
let cert_path = input(
&format!("Where is the TLS certificate for '{hostname}' located?"),
&format!("/etc/letsencrypt/live/{hostname}/fullchain.pem"),
file_exists,
)?;
let pk_path = input(
&format!("Where is the TLS private key for '{hostname}' located?"),
&format!("/etc/letsencrypt/live/{hostname}/privkey.pem"),
file_exists,
)?;
let dkim_instructions = generate_dkim(&base_path, &domain, &hostname)?;
let admin_password = if matches!(directory, Directory::None) {
create_auth_db(&base_path, &domain)?.into()
} else {
None
};
// Write config file
let cfg_path = base_path.join("etc").join("config.toml");
if cfg_path.exists() {
// Rename existing config file
let backup_path = base_path.join("etc").join(format!(
"config.toml.bak.{}",
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
));
fs::rename(&cfg_path, backup_path)?;
}
fs::write(
cfg_path,
cfg_file
.replace("__PATH__", base_path.to_str().unwrap())
.replace("__DOMAIN__", &domain)
.replace("__HOST__", &hostname)
.replace("__CERT_PATH__", &cert_path)
.replace("__PK_PATH__", &pk_path),
)?;
eprintln!("\n🎉 Installation completed!\n\n{dkim_instructions}\n");
if let Some(admin_password) = admin_password {
eprintln!("🔑 The administrator account is 'admin' with password '{admin_password}'.\n",);
}
Ok(())
}
fn select<T: SelectItem>(prompt: &str, items: &[&str], default: T) -> std::io::Result<T> {
if let Some(index) = Select::with_theme(&ColorfulTheme::default())
.items(items)
.with_prompt(prompt)
.default(default.to_index())
.interact_on_opt(&Term::stderr())?
{
Ok(T::from_index(index))
} else {
eprintln!("Aborted.");
std::process::exit(1);
}
}
fn input(
prompt: &str,
default: &str,
validator: fn(&String) -> Result<(), String>,
) -> std::io::Result<String> {
Input::with_theme(&ColorfulTheme::default())
.with_prompt(prompt)
.default(default.to_string())
.validate_with(validator)
.interact_text_on(&Term::stderr())
}
fn dir_create_if_missing(path: &String) -> Result<(), String> {
let path = Path::new(path);
if path.is_dir() {
Ok(())
} else if let Err(e) = std::fs::create_dir_all(path) {
Err(format!(
"Failed to create directory {}: {}",
path.display(),
e
))
} else {
Ok(())
}
}
fn file_exists(path: &String) -> Result<(), String> {
let path = Path::new(path);
if path.is_file() {
Ok(())
} else {
Err(format!("File {} does not exist", path.display()))
}
}
#[allow(clippy::ptr_arg)]
fn not_empty(value: &String) -> Result<(), String> {
if value.trim().is_empty() {
Err("Value cannot be empty".to_string())
} else {
Ok(())
}
}
fn create_directories(path: &Path) -> std::io::Result<()> {
for dir in &DIRECTORIES {
let mut path = PathBuf::from(path);
path.push(dir[0]);
if !path.exists() {
fs::create_dir_all(&path)?;
}
if !dir[1].is_empty() {
path.push(dir[1]);
if !path.exists() {
fs::create_dir_all(&path)?;
}
}
}
Ok(())
}
fn create_auth_db(path: &Path, domain: &str) -> std::io::Result<String> {
let mut path = PathBuf::from(path);
path.push("data");
if !path.exists() {
fs::create_dir_all(&path)?;
}
path.push("accounts.sqlite3");
let conn = Connection::open_with_flags(path, OpenFlags::default()).map_err(|err| {
std::io::Error::new(
std::io::ErrorKind::Other,
format!("Failed to open database: {}", err),
)
})?;
let secret = thread_rng()
.sample_iter(Alphanumeric)
.take(12)
.map(char::from)
.collect::<String>();
let hashed_secret = sha512_crypt::hash(&secret).unwrap();
for query in [
"CREATE TABLE IF NOT EXISTS accounts (name TEXT PRIMARY KEY, secret TEXT, description TEXT, type TEXT NOT NULL, quota INTEGER DEFAULT 0, active BOOLEAN DEFAULT 1)".to_string(),
"CREATE TABLE IF NOT EXISTS group_members (name TEXT NOT NULL, member_of TEXT NOT NULL, PRIMARY KEY (name, member_of))".to_string(),
"CREATE TABLE IF NOT EXISTS emails (name TEXT NOT NULL, address TEXT NOT NULL, type TEXT, PRIMARY KEY (name, address))".to_string(),
format!("INSERT OR REPLACE INTO accounts (name, secret, description, type) VALUES ('admin', '{hashed_secret}', 'Postmaster', 'individual')"),
format!("INSERT OR REPLACE INTO emails (name, address, type) VALUES ('admin', 'postmaster@{domain}', 'primary')"),
"INSERT OR IGNORE INTO group_members (name, member_of) VALUES ('admin', 'superusers')".to_string()
] {
conn.execute(&query, []).map_err(|err| {
std::io::Error::new(
std::io::ErrorKind::Other,
format!("Failed to create database: {}", err),
)
})?;
}
Ok(secret)
}
fn generate_dkim(path: &Path, domain: &str, hostname: &str) -> std::io::Result<String> {
let mut path = PathBuf::from(path);
path.push("etc");
path.push("dkim");
fs::create_dir_all(&path)?;
// Generate key
let rsa = Rsa::generate(2048)?;
let mut public = String::new();
general_purpose::STANDARD.encode_string(rsa.public_key_to_der()?, &mut public);
let private = rsa.private_key_to_pem()?;
// Write private key
let mut pk_path = path.clone();
pk_path.push(&format!("{domain}.key"));
fs::write(pk_path, private)?;
// Write public key
let mut pub_path = path.clone();
pub_path.push(&format!("{domain}.cert"));
fs::write(pub_path, public.as_bytes())?;
// Write instructions
let instructions = format!(
"Add the following DNS records to your domain in order to enable DKIM, SPF and DMARC:\n\
\n\
stalwart._domainkey.{domain}. IN TXT \"v=DKIM1; k=rsa; p={public}\"\n\
{domain}. IN TXT \"v=spf1 a:{hostname} mx -all ra=postmaster\"\n\
{hostname}. IN TXT \"v=spf1 a -all ra=postmaster\"\n\
_dmarc.{domain}. IN TXT \"v=DMARC1; p=none; rua=mailto:postmaster@{domain}; ruf=mailto:postmaster@{domain}\"\n\
",
);
let mut txt_path = path.clone();
txt_path.push(&format!("{domain}.readme"));
fs::write(txt_path, instructions.as_bytes())?;
Ok(instructions)
}
#[cfg(not(target_env = "msvc"))]
unsafe fn get_uid_gid() -> (libc::uid_t, libc::gid_t) {
use std::process::Command;
let pw = libc::getpwnam("stalwart-mail".as_ptr() as *const i8);
let gr = libc::getgrnam("stalwart-mail".as_ptr() as *const i8);
if pw.is_null() || gr.is_null() {
let mut cmd = Command::new("useradd");
cmd.arg("-r")
.arg("-s")
.arg("/sbin/nologin")
.arg("-M")
.arg("stalwart-mail");
if let Err(e) = cmd.status() {
eprintln!("Failed to create stalwart system account: {}", e);
std::process::exit(1);
}
let pw = libc::getpwnam("stalwart-mail".as_ptr() as *const i8);
let gr = libc::getgrnam("stalwart-mail".as_ptr() as *const i8);
(pw.as_ref().unwrap().pw_uid, gr.as_ref().unwrap().gr_gid)
} else {
((*pw).pw_uid, ((*gr).gr_gid))
}
}
trait SelectItem {
fn from_index(index: usize) -> Self;
fn to_index(&self) -> usize;
}
impl SelectItem for Component {
fn from_index(index: usize) -> Self {
match index {
0 => Self::AllInOne,
1 => Self::Jmap,
2 => Self::Imap,
3 => Self::Smtp,
_ => unreachable!(),
}
}
fn to_index(&self) -> usize {
match self {
Self::AllInOne => 0,
Self::Jmap => 1,
Self::Imap => 2,
Self::Smtp => 3,
}
}
}
impl SelectItem for Backend {
fn from_index(index: usize) -> Self {
match index {
0 => Self::SQLite,
1 => Self::FoundationDB,
_ => unreachable!(),
}
}
fn to_index(&self) -> usize {
match self {
Self::SQLite => 0,
Self::FoundationDB => 1,
}
}
}
impl SelectItem for Directory {
fn from_index(index: usize) -> Self {
match index {
0 => Self::SQL,
1 => Self::LDAP,
2 => Self::None,
_ => unreachable!(),
}
}
fn to_index(&self) -> usize {
match self {
Self::SQL => 0,
Self::LDAP => 1,
Self::None => 2,
}
}
}
impl SelectItem for SmtpDirectory {
fn from_index(index: usize) -> Self {
match index {
0 => Self::SQL,
1 => Self::LDAP,
2 => Self::LMTP,
3 => Self::IMAP,
_ => unreachable!(),
}
}
fn to_index(&self) -> usize {
match self {
SmtpDirectory::SQL => 0,
SmtpDirectory::LDAP => 1,
SmtpDirectory::LMTP => 2,
SmtpDirectory::IMAP => 3,
}
}
}
impl SelectItem for Blob {
fn from_index(index: usize) -> Self {
match index {
0 => Blob::Local,
1 => Blob::MinIO,
2 => Blob::S3,
3 => Blob::GCS,
4 => Blob::Azure,
_ => unreachable!(),
}
}
fn to_index(&self) -> usize {
match self {
Blob::Local => 0,
Blob::MinIO => 1,
Blob::S3 => 2,
Blob::GCS => 3,
Blob::Azure => 4,
}
}
}
impl Component {
fn default_base_path(&self) -> &'static str {
match self {
Self::AllInOne => "/opt/stalwart-mail",
Self::Jmap => "/opt/stalwart-jmap",
Self::Imap => "/opt/stalwart-imap",
Self::Smtp => "/opt/stalwart-smtp",
}
}
}

View file

@ -136,10 +136,6 @@ impl crate::Config {
web_socket_timeout: settings.property_or_static("jmap.web-socket.timeout", "10m")?, web_socket_timeout: settings.property_or_static("jmap.web-socket.timeout", "10m")?,
web_socket_heartbeat: settings.property_or_static("jmap.web-socket.heartbeat", "1m")?, web_socket_heartbeat: settings.property_or_static("jmap.web-socket.heartbeat", "1m")?,
push_max_total: settings.property_or_static("jmap.push.max-total", "100")?, push_max_total: settings.property_or_static("jmap.push.max-total", "100")?,
principal_superusers: settings
.value("jmap.principal.superusers")
.unwrap_or("superusers")
.to_string(),
principal_allow_lookups: settings principal_allow_lookups: settings
.property("jmap.principal.allow-lookups")? .property("jmap.principal.allow-lookups")?
.unwrap_or(true), .unwrap_or(true),

View file

@ -40,7 +40,7 @@ use store::{
}; };
use utils::{listener::limiter::InFlight, map::ttl_dashmap::TtlMap}; use utils::{listener::limiter::InFlight, map::ttl_dashmap::TtlMap};
use crate::{JMAP, SUPERUSER_ID}; use crate::JMAP;
use super::{rate_limit::RemoteAddress, AccessToken}; use super::{rate_limit::RemoteAddress, AccessToken};
@ -226,11 +226,7 @@ impl JMAP {
pub async fn map_member_of(&self, names: Vec<String>) -> Result<Vec<u32>, MethodError> { pub async fn map_member_of(&self, names: Vec<String>) -> Result<Vec<u32>, MethodError> {
let mut ids = Vec::with_capacity(names.len()); let mut ids = Vec::with_capacity(names.len());
for name in names { for name in names {
if !name.eq_ignore_ascii_case(&self.config.principal_superusers) {
ids.push(self.get_account_id(&name).await?); ids.push(self.get_account_id(&name).await?);
} else {
ids.push(SUPERUSER_ID);
}
} }
Ok(ids) Ok(ids)
} }

View file

@ -31,7 +31,7 @@ use aes_gcm_siv::{
AeadInPlace, Aes256GcmSiv, KeyInit, Nonce, AeadInPlace, Aes256GcmSiv, KeyInit, Nonce,
}; };
use directory::Principal; use directory::{Principal, Type};
use jmap_proto::{ use jmap_proto::{
error::method::MethodError, error::method::MethodError,
types::{collection::Collection, id::Id}, types::{collection::Collection, id::Id},
@ -39,8 +39,6 @@ use jmap_proto::{
use store::blake3; use store::blake3;
use utils::map::bitmap::Bitmap; use utils::map::bitmap::Bitmap;
use crate::SUPERUSER_ID;
pub mod acl; pub mod acl;
pub mod authenticate; pub mod authenticate;
pub mod oauth; pub mod oauth;
@ -54,6 +52,7 @@ pub struct AccessToken {
pub name: String, pub name: String,
pub description: Option<String>, pub description: Option<String>,
pub quota: u32, pub quota: u32,
pub is_superuser: bool,
} }
impl AccessToken { impl AccessToken {
@ -65,6 +64,7 @@ impl AccessToken {
name: principal.name, name: principal.name,
description: principal.description, description: principal.description,
quota: principal.quota, quota: principal.quota,
is_superuser: principal.typ == Type::Superuser,
} }
} }
@ -95,9 +95,7 @@ impl AccessToken {
} }
pub fn is_member(&self, account_id: u32) -> bool { pub fn is_member(&self, account_id: u32) -> bool {
self.primary_id == account_id self.primary_id == account_id || self.member_of.contains(&account_id) || self.is_superuser
|| self.member_of.contains(&account_id)
|| self.member_of.contains(&SUPERUSER_ID)
} }
pub fn is_primary_id(&self, account_id: u32) -> bool { pub fn is_primary_id(&self, account_id: u32) -> bool {
@ -105,7 +103,7 @@ impl AccessToken {
} }
pub fn is_super_user(&self) -> bool { pub fn is_super_user(&self) -> bool {
self.member_of.contains(&SUPERUSER_ID) self.is_superuser
} }
pub fn is_shared(&self, account_id: u32) -> bool { pub fn is_shared(&self, account_id: u32) -> bool {

View file

@ -78,7 +78,6 @@ pub mod thread;
pub mod vacation; pub mod vacation;
pub mod websocket; pub mod websocket;
pub const SUPERUSER_ID: u32 = 0;
pub const LONG_SLUMBER: Duration = Duration::from_secs(60 * 60 * 24); pub const LONG_SLUMBER: Duration = Duration::from_secs(60 * 60 * 24);
pub struct JMAP { pub struct JMAP {
@ -151,7 +150,6 @@ pub struct Config {
pub oauth_expiry_refresh_token_renew: u64, pub oauth_expiry_refresh_token_renew: u64,
pub oauth_max_auth_attempts: u32, pub oauth_max_auth_attempts: u32,
pub principal_superusers: String,
pub principal_allow_lookups: bool, pub principal_allow_lookups: bool,
pub capabilities: BaseCapabilities, pub capabilities: BaseCapabilities,

View file

@ -755,7 +755,7 @@ impl JMAP {
} }
#[cfg(feature = "test_mode")] #[cfg(feature = "test_mode")]
if mailbox_ids.is_empty() && account_id == crate::SUPERUSER_ID { if mailbox_ids.is_empty() && account_id == 0 {
return Ok(mailbox_ids); return Ok(mailbox_ids);
} }

View file

@ -23,6 +23,7 @@
use std::{borrow::Cow, fmt::Display, net::IpAddr, sync::Arc, time::Instant}; use std::{borrow::Cow, fmt::Display, net::IpAddr, sync::Arc, time::Instant};
use directory::Type;
use http_body_util::{combinators::BoxBody, BodyExt, Empty, Full}; use http_body_util::{combinators::BoxBody, BodyExt, Empty, Full};
use hyper::{ use hyper::{
body::{self, Bytes}, body::{self, Bytes},
@ -256,9 +257,16 @@ impl SMTP {
.authenticate(&Credentials::Plain { username, secret }) .authenticate(&Credentials::Plain { username, secret })
.await .await
{ {
Ok(Some(_)) => { Ok(Some(principal)) if principal.typ == Type::Superuser => {
is_authenticated = true; is_authenticated = true;
} }
Ok(Some(_)) => {
tracing::debug!(
context = "management",
event = "auth-error",
"Insufficient privileges."
);
}
Ok(None) => { Ok(None) => {
tracing::debug!( tracing::debug!(
context = "management", context = "management",

View file

@ -39,7 +39,7 @@ impl Store {
pub async fn open(config: &Config) -> crate::Result<Self> { pub async fn open(config: &Config) -> crate::Result<Self> {
let db = Self { let db = Self {
conn_pool: Pool::builder() conn_pool: Pool::builder()
.max_size(config.property_or_static("store.db.connection-pool.size", "10")?) .max_size(config.property_or_static("store.db.pool.max-connections", "10")?)
.build( .build(
SqliteConnectionManager::file( SqliteConnectionManager::file(
config config
@ -58,7 +58,7 @@ impl Store {
worker_pool: rayon::ThreadPoolBuilder::new() worker_pool: rayon::ThreadPoolBuilder::new()
.num_threads( .num_threads(
config config
.property::<usize>("store.db.worker-pool.size")? .property::<usize>("store.db.pool.workers")?
.filter(|v| *v > 0) .filter(|v| *v > 0)
.unwrap_or_else(num_cpus::get), .unwrap_or_else(num_cpus::get),
) )
@ -67,7 +67,7 @@ impl Store {
crate::Error::InternalError(format!("Failed to build worker pool: {}", err)) crate::Error::InternalError(format!("Failed to build worker pool: {}", err))
})?, })?,
id_assigner: Arc::new(Mutex::new(LruCache::new( id_assigner: Arc::new(Mutex::new(LruCache::new(
config.property_or_static("store.db.id-cache.size", "1000")?, config.property_or_static("store.db.cache.size", "1000")?,
))), ))),
blob: BlobStore::new(config).await?, blob: BlobStore::new(config).await?,
}; };

View file

@ -1,10 +1,14 @@
#############################################
# Server configuration
#############################################
[server] [server]
hostname = "__HOST__" hostname = "__HOST__"
max-connections = 8192 max-connections = 8192
[server.run-as] [server.run-as]
user = "__RUN_AS_USER__" user = "stalwart-mail"
group = "__RUN_AS_GROUP__" group = "stalwart-mail"
[server.tls] [server.tls]
enable = true enable = true
@ -49,5 +53,5 @@ rotate = "daily"
level = "info" level = "info"
[certificate."default"] [certificate."default"]
cert = "file://__PATH__/etc/certs/tls.crt" cert = "file://__CERT_PATH__"
private-key = "file://__PATH__/etc/private/tls.key" private-key = "file://__PK_PATH__"

View file

@ -1,13 +1,24 @@
#############################################
# Directory configuration
#############################################
[directory."sql"] [directory."sql"]
type = "sql" type = "sql"
address = "sqlite::memory:" address = "sqlite://__PATH__/data/accounts.sqlite3?mode=rwc"
[directory."sql".options] [directory."sql".options]
catch-all = true catch-all = true
subaddressing = true subaddressing = true
superuser-group = "superusers"
[directory."sql".pool] [directory."sql".pool]
max-connections = 10 max-connections = 10
min-connections = 0
#idle-timeout = "10m"
[directory."sql".cache]
entries = 500
ttl = {positive = '1h', negative = '10m'}
[directory."sql".query] [directory."sql".query]
name = "SELECT name, type, secret, description, quota FROM accounts WHERE name = ? AND active = true" name = "SELECT name, type, secret, description, quota FROM accounts WHERE name = ? AND active = true"
@ -35,9 +46,21 @@ base-dn = "dc=example,dc=org"
dn = "cn=serviceuser,ou=svcaccts,dc=example,dc=org" dn = "cn=serviceuser,ou=svcaccts,dc=example,dc=org"
secret = "mysecret" secret = "mysecret"
[directory."ldap".cache]
entries = 500
ttl = {positive = '1h', negative = '10m'}
[directory."ldap".options] [directory."ldap".options]
catch-all = true catch-all = true
subaddressing = true subaddressing = true
superuser-group = "superusers"
[directory."ldap".pool]
max-connections = 10
min-connections = 0
max-lifetime = "30m"
idle-timeout = "10m"
connect-timeout = "30s"
[directory."ldap".filter] [directory."ldap".filter]
name = "(&(|(objectClass=posixAccount)(objectClass=posixGroup))(uid=?))" name = "(&(|(objectClass=posixAccount)(objectClass=posixGroup))(uid=?))"
@ -62,40 +85,52 @@ quota = "diskQuota"
[directory."imap"] [directory."imap"]
type = "imap" type = "imap"
address = "127.0.0.1" address = "127.0.0.1"
port = 9198 port = 993
[directory."imap".pool] [directory."imap".pool]
max-connections = 5 max-connections = 10
min-connections = 0
max-lifetime = "30m"
idle-timeout = "10m"
connect-timeout = "30s"
[directory."imap".tls] [directory."imap".tls]
implicit = true implicit = true
allow-invalid-certs = true allow-invalid-certs = true
[directory."imap".lookup] [directory."imap".cache]
domains = ["example.org"] entries = 500
ttl = {positive = '1h', negative = '10m'}
[directory."smtp"] [directory."imap".lookup]
domains = ["__DOMAIN__"]
[directory."lmtp"]
type = "lmtp" type = "lmtp"
address = "127.0.0.1" address = "127.0.0.1"
port = 9199 port = 11200
[directory."smtp".limits] [directory."lmtp".limits]
auth-errors = 3 auth-errors = 3
rcpt = 5 rcpt = 5
[directory."smtp".pool] [directory."lmtp".pool]
max-connections = 5 max-connections = 10
min-connections = 0
max-lifetime = "30m"
idle-timeout = "10m"
connect-timeout = "30s"
[directory."smtp".tls] [directory."lmtp".tls]
implicit = true implicit = false
allow-invalid-certs = true allow-invalid-certs = true
[directory."smtp".cache] [directory."lmtp".cache]
entries = 500 entries = 500
ttl = {positive = '10s', negative = '5s'} ttl = {positive = '1h', negative = '10m'}
[directory."smtp".lookup] [directory."lmtp".lookup]
domains = ["example.org"] domains = ["__DOMAIN__"]
[directory."memory"] [directory."memory"]
type = "memory" type = "memory"
@ -103,29 +138,30 @@ type = "memory"
[directory."memory".options] [directory."memory".options]
catch-all = true catch-all = true
subaddressing = true subaddressing = true
superuser-group = "superusers"
[[directory."memory".users]] [[directory."memory".users]]
name = "admin" name = "admin"
description = "Superuser" description = "Superuser"
secret = "changeme" secret = "changeme"
email = ["admin@example.org"] email = ["postmaster@__DOMAIN__"]
member-of = ["superusers"] member-of = ["superusers"]
[[directory."memory".users]] [[directory."memory".users]]
name = "jane" name = "jane"
description = "Jane Doe" description = "Jane Doe"
secret = "abcde" secret = "abcde"
email = ["jane@example.org", "jane.doe@example.org"] email = ["jane@__DOMAIN__", "jane.doe@__DOMAIN__"]
email-list = ["info@example.org"] email-list = ["info@__DOMAIN__"]
member-of = ["sales", "support"] member-of = ["sales", "support"]
[[directory."memory".users]] [[directory."memory".users]]
name = "bill" name = "bill"
description = "Bill Foobar" description = "Bill Foobar"
secret = "$2y$05$bvIG6Nmid91Mu9RcmmWZfO5HJIMCT8riNW0hEp8f6/FuA2/mHZFpe" secret = "$2y$05$bvIG6Nmid91Mu9RcmmWZfO5HJIMCT8riNW0hEp8f6/FuA2/mHZFpe"
quota = 500000 quota = 50000000
email = "bill@example.org" email = ["bill@__DOMAIN__", "bill.foobar@__DOMAIN__"]
email-list = ["info@example.org"] email-list = ["info@__DOMAIN__"]
[[directory."memory".groups]] [[directory."memory".groups]]
name = "sales" name = "sales"
@ -136,4 +172,4 @@ name = "support"
description = "Support Team" description = "Support Team"
[directory."memory".lookup] [directory."memory".lookup]
domains = ["example.org"] domains = ["__DOMAIN__"]

View file

@ -1,9 +1,13 @@
#############################################
# IMAP server configuration
#############################################
[server.listener."imap"] [server.listener."imap"]
bind = ["0.0.0.0:143"] bind = ["0.0.0.0:143"]
protocol = "imap" protocol = "imap"
[server.listener."imaptls"] [server.listener."imaptls"]
bind = ["0.0.0.0:9993"] bind = ["0.0.0.0:993"]
protocol = "imap" protocol = "imap"
tls.implicit = true tls.implicit = true

View file

@ -1,16 +1,27 @@
#############################################
# JMAP server configuration
#############################################
[server.listener."jmap"] [server.listener."jmap"]
bind = ["0.0.0.0:__BIND_PORT__"] bind = ["0.0.0.0:8080"]
url = "https://127.0.0.1:__BIND_PORT__" url = "https://__HOST__:8080"
protocol = "jmap" protocol = "jmap"
[store.db] [store.db]
path = "__PATH__/db" path = "__PATH__/data/index.sqlite3"
[store.db.pool]
max-connections = 10
#workers = 8
[store.db.cache]
size = 1000
[store.blob] [store.blob]
type = "local" type = "__BLOB_STORE__"
[store.blob.local] [store.blob.local]
path = "__PATH__/blobs" path = "__PATH__/data/blobs"
[store.blob.s3] [store.blob.s3]
bucket = "stalwart" bucket = "stalwart"
@ -23,7 +34,7 @@ secret-key = "minioadmin"
timeout = "30s" timeout = "30s"
[jmap] [jmap]
directory = "sql" directory = "__DIRECTORY__"
[jmap.session.cache] [jmap.session.cache]
ttl = "1h" ttl = "1h"
@ -74,6 +85,9 @@ max-size = 75000000
[jmap.email.parse] [jmap.email.parse]
max-items = 10 max-items = 10
[jmap.principal]
allow-lookups = true
[jmap.sieve] [jmap.sieve]
disable-capabilities = [] disable-capabilities = []
notification-uris = ["mailto"] notification-uris = ["mailto"]

View file

@ -1,3 +1,7 @@
#############################################
# SMTP server configuration
#############################################
[server.listener."smtp"] [server.listener."smtp"]
bind = ["0.0.0.0:25"] bind = ["0.0.0.0:25"]
greeting = "Stalwart SMTP at your service" greeting = "Stalwart SMTP at your service"
@ -51,7 +55,7 @@ mt-priority = [ { if = "authenticated-as", ne = "", then = "mixer"},
[session.auth] [session.auth]
mechanisms = [ { if = "listener", ne = "smtp", then = ["plain", "login"]}, mechanisms = [ { if = "listener", ne = "smtp", then = ["plain", "login"]},
{ else = [] } ] { else = [] } ]
directory = [ { if = "listener", ne = "smtp", then = "local" }, directory = [ { if = "listener", ne = "smtp", then = "__SMTP_DIRECTORY__" },
{ else = false } ] { else = false } ]
require = [ { if = "listener", ne = "smtp", then = true}, require = [ { if = "listener", ne = "smtp", then = true},
{ else = false } ] { else = false } ]
@ -68,7 +72,7 @@ wait = "5s"
relay = [ { if = "authenticated-as", ne = "", then = true }, relay = [ { if = "authenticated-as", ne = "", then = true },
{ else = false } ] { else = false } ]
max-recipients = 25 max-recipients = 25
directory = [ { if = "authenticated-as", ne = "", then = "local" }, directory = [ { if = "authenticated-as", ne = "", then = "__SMTP_DIRECTORY__" },
{ else = false } ] { else = false } ]
[session.rcpt.cache] [session.rcpt.cache]
@ -157,7 +161,7 @@ expire = "5d"
[queue.outbound] [queue.outbound]
#hostname = "__HOST__" #hostname = "__HOST__"
next-hop = [ { if = "rcpt-domain", in-list = "local/domains", then = "lmtp" }, next-hop = [ { if = "rcpt-domain", in-list = "__SMTP_DIRECTORY__/domains", then = "__NEXT_HOP__" },
{ else = false } ] { else = false } ]
ip-strategy = "ipv4-then-ipv6" ip-strategy = "ipv4-then-ipv6"
@ -267,10 +271,10 @@ max-size = 26214400 # 25 mb
sign = ["rsa"] sign = ["rsa"]
[signature."rsa"] [signature."rsa"]
#public-key = "file://__PATH__/etc/certs/dkim.crt" #public-key = "file://__PATH__/etc/dkim/__DOMAIN__.cert"
private-key = "file://__PATH__/etc/private/dkim.key" private-key = "file://__PATH__/etc/dkim/__DOMAIN__.key"
domain = "__DOMAIN__" domain = "__DOMAIN__"
selector = "stalwart_smtp" selector = "stalwart"
headers = ["From", "To", "Date", "Subject", "Message-ID"] headers = ["From", "To", "Date", "Subject", "Message-ID"]
algorithm = "rsa-sha256" algorithm = "rsa-sha256"
canonicalization = "relaxed/relaxed" canonicalization = "relaxed/relaxed"
@ -282,8 +286,8 @@ set-body-length = false
report = true report = true
[remote."lmtp"] [remote."lmtp"]
address = "__LMTP_HOST__" address = "127.0.0.1"
port = __LMTP_PORT__ port = 11200
protocol = "lmtp" protocol = "lmtp"
concurrency = 10 concurrency = 10
timeout = "1m" timeout = "1m"
@ -296,32 +300,13 @@ allow-invalid-certs = true
#username = "" #username = ""
#secret = "" #secret = ""
[database."sql"]
#address = "sqlite://__PATH__/etc/sqlite.db?mode=rwc"
address = "postgres://postgres:password@localhost/test"
max-connections = 10
min-connections = 0
idle-timeout = "5m"
[database."sql".lookup]
auth = "SELECT secret FROM users WHERE email=?"
rcpt = "SELECT EXISTS(SELECT 1 FROM users WHERE email=? LIMIT 1)"
vrfy = "SELECT email FROM users WHERE email LIKE '%' || ? || '%' LIMIT 5"
expn = "SELECT member FROM mailing_lists WHERE id = ?"
domains = "SELECT EXISTS(SELECT 1 FROM domains WHERE name=? LIMIT 1)"
[database."sql".cache]
enable = ["rcpt", "domains"]
entries = 1000
ttl = {positive = "1d", negative = "1h"}
[sieve] [sieve]
from-name = "Automated Message" from-name = "Automated Message"
from-addr = "no-reply@__DOMAIN__" from-addr = "no-reply@__DOMAIN__"
return-path = "" return-path = ""
#hostname = "__HOST__" #hostname = "__HOST__"
sign = ["rsa"] sign = ["rsa"]
use-directory = "sql" use-directory = "__SMTP_DIRECTORY__"
[sieve.limits] [sieve.limits]
redirects = 3 redirects = 3
@ -337,14 +322,14 @@ duplicate-expiry = "7d"
connect = ''' connect = '''
require ["variables", "extlists", "reject"]; require ["variables", "extlists", "reject"];
if string :list "${env.remote_ip}" "local/blocked-ips" { if string :list "${env.remote_ip}" "__SMTP_DIRECTORY__/blocked-ips" {
reject "Your IP '${env.remote_ip}' is not welcomed here."; reject "Your IP '${env.remote_ip}' is not welcomed here.";
} }
''' '''
ehlo = ''' ehlo = '''
require ["variables", "extlists", "reject"]; require ["variables", "extlists", "reject"];
if string :list "${env.helo_domain}" "local/blocked-domains" { if string :list "${env.helo_domain}" "__SMTP_DIRECTORY__/blocked-domains" {
reject "551 5.1.1 Your domain '${env.helo_domain}' has been blacklisted."; reject "551 5.1.1 Your domain '${env.helo_domain}' has been blacklisted.";
} }
''' '''
@ -383,5 +368,4 @@ data = '''
''' '''
[management] [management]
directory = "local" directory = "__DIRECTORY__"

View file

@ -182,10 +182,6 @@ description = "Sales Team"
name = "support" name = "support"
description = "Support Team" description = "Support Team"
[[directory."local".groups]]
name = "superusers"
description = "Superusers"
[oauth] [oauth]
key = "parerga_und_paralipomena" key = "parerga_und_paralipomena"
oauth.auth.max-attempts = 1 oauth.auth.max-attempts = 1

View file

@ -50,10 +50,14 @@ const DIRECTORY: &str = r#"
[directory."local"] [directory."local"]
type = "memory" type = "memory"
[directory."local".options]
superuser-group = "superusers"
[[directory."local".users]] [[directory."local".users]]
name = "admin" name = "admin"
description = "Superuser" description = "Superuser"
secret = "secret" secret = "secret"
member-of = ["superusers"]
"#; "#;

View file

@ -53,10 +53,14 @@ const DIRECTORY: &str = r#"
[directory."local"] [directory."local"]
type = "memory" type = "memory"
[directory."local".options]
superuser-group = "superusers"
[[directory."local".users]] [[directory."local".users]]
name = "admin" name = "admin"
description = "Superuser" description = "Superuser"
secret = "secret" secret = "secret"
member-of = ["superusers"]
"#; "#;