Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
ed0187e
feat(config): add RUSTMAIL_* env var support
Akinator31 Jun 17, 2026
c7df109
feat(setup): add backend wizard endpoints, validation and state
Akinator31 Jun 17, 2026
3afbe5c
feat(panel): add setup route and auto-detection
Akinator31 Jun 17, 2026
f16b199
feat(panel): add wizard layout and progress bar
Akinator31 Jun 17, 2026
8c24bab
feat(panel): implement step 1 (bot token validation)
Akinator31 Jun 17, 2026
f949b68
feat(panel): implement step 2 (server mode and guilds validation)
Akinator31 Jun 17, 2026
74f6c13
feat(panel): implement step 3 (thread config and category validation)
Akinator31 Jun 17, 2026
6dccede
feat(panel): implement step 4 (web panel and oauth2 config)
Akinator31 Jun 17, 2026
44e59b9
feat(panel): implement step 5 (language and messages)
Akinator31 Jun 17, 2026
40230c9
feat(panel): implement step 6 (review and save)
Akinator31 Jun 17, 2026
0280813
refactor(setup): graceful transition from setup wizard to bot execution
Akinator31 Jun 17, 2026
ade1403
fix(setup): correctly append port to ip addresses for redirect url
Akinator31 Jun 17, 2026
e15d191
refactor(wizard): simplify panel URL setup and add auto-detection for…
Akinator31 Jun 17, 2026
123ed92
feat(panel): fully implement i18n for the setup wizard
Akinator31 Jun 17, 2026
17ae2c8
fix(clippy): fix clippy warnings
Akinator31 Jun 17, 2026
7940918
fix: address PR review feedback for setup and logs
Copilot Jun 17, 2026
6eb8443
feat(setup): require a session token to access the setup wizard API
Akinator31 Jul 18, 2026
ca58e06
feat(wizard): persist progress across reloads and add a close message…
Akinator31 Jul 18, 2026
413ada4
fix(i18n): remove unnecessary title-case capitalization from French s…
Akinator31 Jul 18, 2026
f3a9e3a
fix(wizard): don't persist bot token/client secret to localStorage
Akinator31 Jul 18, 2026
8cb6260
refactor(setup): share the setup-token header name as one constant
Akinator31 Jul 18, 2026
978855a
fix(setup): make completion detection explicit and return the saved p…
Akinator31 Jul 18, 2026
62ead59
fix(wizard): surface a 401 on the restart button instead of redirecti…
Akinator31 Jul 18, 2026
e501b15
refactor(wizard): share the validate-step 401/error handling in one h…
Akinator31 Jul 18, 2026
290af87
refactor(wizard): replace 4 boolean page-mode flags with one enum
Akinator31 Jul 18, 2026
ee740ce
fix(wizard): use sessionStorage for wizard progress so the token surv…
Akinator31 Jul 18, 2026
b714d06
docs: update configuration documentation
Akinator31 Jul 21, 2026
dbd1b28
fix(wizard): fallback api_port to 3002 instead of 8080
Akinator31 Jul 21, 2026
3353eaf
fix(wizard): bind setup URL to localhost or resolved local IP
Akinator31 Jul 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 13 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,22 +34,25 @@ send direct messages to the bot, which creates dedicated channels for staff to r

## Quick Start

### 1. Generate your configuration
### 1. Download and run

Use the online configurator to create your `config.toml` file:
Download the latest release for your platform from [Releases](https://github.com/Rustmail/rustmail/releases), then run the executable:

**[config.rustmail.rs](https://config.rustmail.rs)**

The configurator guides you through all required settings and generates a ready-to-use configuration file.
```bash
./rustmail
```

### 2. Download and run
### 2. Follow the setup wizard

Download the latest release for your platform from [Releases](https://github.com/Rustmail/rustmail/releases), then:
If no `config.toml` is found, Rustmail launches a built-in setup wizard and prints a one-time URL to the console:

```bash
# Place config.toml in the same directory as the executable
./rustmail
```
No configuration found.
Setup wizard available at http://0.0.0.0:3002/setup?token=...
```

Open that URL in your browser to configure your bot token, server mode, ticket settings, web panel, and language — the
wizard writes a ready-to-use `config.toml` for you and restarts the bot automatically once you're done.

The bot creates its SQLite database automatically on first run.

Expand Down
3 changes: 2 additions & 1 deletion crates/rustmail/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,11 @@ base64 = "0.22"
moka = { version = "0.12.15", features = ["future"] }
tower-http = { version = "0.7.0", features = ["compression-gzip", "compression-br"] }
strum = { version = "0.28.0", features = ["derive"] }
chrono-tz = "0.10.4"

[dependencies.uuid]
version = "1.23.3"
features = ["v4"]

[dev-dependencies]
cargo-husky = { version = "1.5.0", default-features = false, features = ["run-for-all", "run-cargo-fmt", "precommit-hook", "run-cargo-test", "run-cargo-check"] }
cargo-husky = { version = "1.5.0", default-features = false, features = ["run-for-all", "run-cargo-fmt", "precommit-hook", "run-cargo-test", "run-cargo-check"] }
24 changes: 11 additions & 13 deletions crates/rustmail/src/api/handler/admin/permissions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,20 +58,18 @@ pub async fn handle_list_permissions(
row.try_get::<String, _>("permission"),
row.try_get::<String, _>("granted_by"),
row.try_get::<i64, _>("granted_at"),
) && let (Some(st), Some(perm)) = (
SubjectType::from_str(&subject_type),
PanelPermission::from_str(&permission),
) {
if let (Some(st), Some(perm)) = (
SubjectType::from_str(&subject_type),
PanelPermission::from_str(&permission),
) {
permissions.push(PanelPermissionEntry {
id,
subject_type: st,
subject_id,
permission: perm,
granted_by,
granted_at,
});
}
permissions.push(PanelPermissionEntry {
id,
subject_type: st,
subject_id,
permission: perm,
granted_by,
granted_at,
});
}
}

Expand Down
2 changes: 1 addition & 1 deletion crates/rustmail/src/api/handler/auth/logout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ pub async fn handle_logout(

if let Some(session_cookie) = session_cookie {
let session_id = session_cookie.value();
let user_id = get_user_id_from_session(&session_id, &db_pool).await;
let user_id = get_user_id_from_session(session_id, &db_pool).await;

if let Some(db_pool) = &state_lock.db_pool {
let _ = sqlx::query("DELETE FROM sessions_panel WHERE session_id = ? AND user_id = ?")
Expand Down
57 changes: 1 addition & 56 deletions crates/rustmail/src/api/handler/bot/config.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
use crate::config::{Config, LanguageConfigExt, load_config};
use crate::config::{Config, load_config, save_config_with_backup, validate_config};
use crate::prelude::types::*;
use axum::Json;
use axum::extract::State;
use axum::http::StatusCode;
use rustmail_types::ConfigResponse;
use std::fs;
use std::sync::Arc;
use tokio::sync::Mutex;

Expand Down Expand Up @@ -105,57 +104,3 @@ pub async fn handle_update_config(
"message": "Configuration saved successfully. Restart the bot to apply changes."
})))
}

fn validate_config(config: &Config) -> Result<(), String> {
if u64::from_str_radix(&config.thread.user_message_color, 16).is_err() {
return Err("Invalid user message color format (must be hex)".to_string());
}

if u64::from_str_radix(&config.thread.staff_message_color, 16).is_err() {
return Err("Invalid staff message color format (must be hex)".to_string());
}

if u64::from_str_radix(&config.reminders.embed_color, 16).is_err() {
return Err("Invalid reminder embed color format (must be hex)".to_string());
}

config.bot.validate_logs_config()?;
config.bot.validate_features_config()?;

if !config
.language
.is_language_supported(config.language.get_default_language())
{
return Err(format!(
"Default language '{}' is not in supported languages list",
config.language.default_language
));
}

Ok(())
}

async fn save_config_with_backup(config: &Config, path: &str) -> Result<(), String> {
if std::path::Path::new(path).exists() {
let backup_path = format!("{}.backup", path);
fs::copy(path, &backup_path).map_err(|e| format!("Failed to create backup: {}", e))?;
}

let config_response = ConfigResponse {
bot: config.bot.clone(),
command: config.command.clone(),
thread: config.thread.clone(),
language: config.language.clone(),
error_handling: config.error_handling.clone(),
notifications: config.notifications.clone(),
reminders: config.reminders.clone(),
logs: config.logs.clone(),
};

let toml_content = toml::to_string_pretty(&config_response)
.map_err(|e| format!("Failed to serialize config: {}", e))?;

fs::write(path, toml_content).map_err(|e| format!("Failed to write config file: {}", e))?;

Ok(())
}
59 changes: 29 additions & 30 deletions crates/rustmail/src/api/handler/bot/tickets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -240,37 +240,36 @@ pub async fn handle_tickets_bot(
messages,
};

if !is_admin {
if let Some(ref category_id) = complete.category_id {
if !category_id.is_empty() {
match get_user_permissions_in_category(
&user_id,
guild_id,
category_id,
bot_http.clone(),
)
.await
{
Some(perms) => {
if !can_view_channel(perms) {
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "You don't have permission to view this ticket"
})),
);
}
}
None => {
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "Failed to check permissions"
})),
);
}
if !is_admin
&& let Some(ref category_id) = complete.category_id
&& !category_id.is_empty()
{
match get_user_permissions_in_category(
&user_id,
guild_id,
category_id,
bot_http.clone(),
)
.await
{
Some(perms) => {
if !can_view_channel(perms) {
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "You don't have permission to view this ticket"
})),
);
}
}
None => {
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "Failed to check permissions"
})),
);
}
}
}

Expand Down Expand Up @@ -444,7 +443,7 @@ pub async fn handle_tickets_bot(
for msg in all_messages {
messages_by_thread
.entry(msg.1.clone())
.or_insert_with(Vec::new)
.or_default()
.push(ThreadMessage {
id: msg.0,
thread_id: msg.1.clone(),
Expand Down
44 changes: 22 additions & 22 deletions crates/rustmail/src/api/handler/categories/categories.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,37 +150,37 @@ pub async fn update_category_handler(
return Err((StatusCode::BAD_REQUEST, "Name required".to_string()));
}

if let Some(conflict) = get_category_by_name(name, &p).await.map_err(internal)? {
if conflict.id != existing.id {
return Err((
StatusCode::CONFLICT,
"Category with this name already exists".to_string(),
));
}
}
}

if let Some(true) = req.enabled {
if !existing.enabled {
let enabled_count = count_enabled_categories(&p).await.map_err(internal)?;
if enabled_count as usize >= CATEGORY_BUTTON_HARD_LIMIT {
return Err((
StatusCode::BAD_REQUEST,
format!("Maximum {} enabled categories", CATEGORY_BUTTON_HARD_LIMIT),
));
}
if let Some(conflict) = get_category_by_name(name, &p).await.map_err(internal)?
&& conflict.id != existing.id
{
return Err((
StatusCode::CONFLICT,
"Category with this name already exists".to_string(),
));
}
}

if let Some(ref did) = req.discord_category_id {
if did.parse::<u64>().is_err() {
if let Some(true) = req.enabled
&& !existing.enabled
{
let enabled_count = count_enabled_categories(&p).await.map_err(internal)?;
if enabled_count as usize >= CATEGORY_BUTTON_HARD_LIMIT {
return Err((
StatusCode::BAD_REQUEST,
"Invalid discord_category_id".to_string(),
format!("Maximum {} enabled categories", CATEGORY_BUTTON_HARD_LIMIT),
));
}
}

if let Some(ref did) = req.discord_category_id
&& did.parse::<u64>().is_err()
{
return Err((
StatusCode::BAD_REQUEST,
"Invalid discord_category_id".to_string(),
));
}

update_category(
&id,
trimmed_name,
Expand Down
8 changes: 4 additions & 4 deletions crates/rustmail/src/api/handler/health.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ pub async fn handle_health(State(bot_state): State<Arc<Mutex<BotState>>>) -> imp
let state_lock = bot_state.lock().await;

let mut db_ok = false;
if let Some(pool) = &state_lock.db_pool {
if sqlx::query("SELECT 1").execute(pool).await.is_ok() {
db_ok = true;
}
if let Some(pool) = &state_lock.db_pool
&& sqlx::query("SELECT 1").execute(pool).await.is_ok()
{
db_ok = true;
}

let bot_running = matches!(state_lock.status, BotStatus::Running { .. });
Expand Down
Loading