9 Commits
0.1.3 ... 0.1.4

Author SHA1 Message Date
rustmailer
a1453f3cda Document environment variable quotes in container usage
Added notes on running Bichon in a container to README.
2025-12-07 00:42:10 +08:00
rustmailer
9e656b1f91 Bump version from 0.1.3 to 0.1.4 2025-12-07 00:22:41 +08:00
rustmailer
afac192280 Enhance CORS configuration section in README
Added detailed CORS configuration instructions for Bichon, including new behavior in v0.1.4 and examples for setting origins.
2025-12-07 00:22:07 +08:00
rustmailer
c00ffe8d11 feat(cors): remove default value for BICHON_CORS_ORIGINS and allow all origins when unset
- Changed behavior so that when BICHON_CORS_ORIGINS is not configured, CORS now allows any origin.
- Added debug logging to print incoming Origin and configured origins to help users diagnose CORS misconfiguration issues.
2025-12-07 00:03:21 +08:00
rustmailer
c90a2d552d Fix FAQ link in README.md 2025-12-06 22:04:35 +08:00
rustmailer
a4627a63cb Update README.md 2025-12-06 22:03:36 +08:00
rustmailer
684b9765fe Update README.md 2025-12-06 20:33:51 +08:00
rustmailer
b0ce7671a8 chore(ui): move name field to step 2 #30 2025-12-04 08:33:16 +08:00
rustmailer
4b13bf14c4 Fix(sync): missing initial sync start time after enabling a previously disabled account #32 2025-12-03 20:11:19 +08:00
10 changed files with 174 additions and 82 deletions

View File

@@ -1,6 +1,6 @@
[package]
name = "bichon"
version = "0.1.3"
version = "0.1.4"
edition = "2021"
[[bin]]

140
README.md
View File

@@ -22,7 +22,7 @@
<img src="https://img.shields.io/badge/license-AGPLv3-blue.svg" alt="License">
</a>
<a href="https://deepwiki.com/rustmailer/bichon"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"></a>
<a href="https://discord.gg/evFnSpdpaE">
<a href="https://discord.gg/Bq4M2cDmF4">
<img src="https://img.shields.io/badge/Discord-Join%20Server-7289DA?logo=discord&logoColor=white" alt="Discord">
</a>
<a href="https://x.com/rustmailer">
@@ -151,41 +151,116 @@ docker run -d \
rustmailer/bichon:latest
```
## CORS Configuration (Important for Browser Access)
* **Accessing Bichon from a browser:**
You need to add the exact address you use in your browser to `BICHON_CORS_ORIGINS`.
Starting from **v0.1.4**, Bichon changes how `BICHON_CORS_ORIGINS` works:
* If you access via **IP**, add `IP:port`, e.g.:
### **🔄 New Behavior in v0.1.4**
```
http://192.168.1.16:15630
```
* If you access via **hostname**, add `hostname:port`, e.g.:
* If **`BICHON_CORS_ORIGINS` is not set**, Bichon now **allows all origins**.
This makes local testing and simple deployments much easier.
* If you **do set** `BICHON_CORS_ORIGINS`, then **you must explicitly list each allowed origin**.
* `*` is **not supported** and will **not work** — you must provide exact URLs.
```
http://myserver.local:15630
```
* If you access via **domain name**, add the domain, e.g.:
#### How CORS Matching Works
```
http://mydomain.com
```
* **If Bichon is running on port 80**, you **do not need to include the port**.
* If you want to access Bichon in **multiple ways**, include all of them separated by commas.
When a browser accesses Bichon, it will send an `Origin` header.
Example Docker run:
* **Incoming Origin** = the exact address the browser is using
* **Configured origins** = the list you passed to `BICHON_CORS_ORIGINS`
If Configured origins does not contain the Incoming Origin exactly as a full string match, the browser request will be rejected.
Example debug log:
```
2025-12-06T23:56:30.422+08:00 DEBUG bichon::modules::rest: CORS: Incoming Origin = "http://localhost:15630"
2025-12-06T23:56:30.422+08:00 DEBUG bichon::modules::rest: CORS: Configured origins = ["http://192.168.3.2:15630"]
```
In this example:
* Browser is using `http://localhost:15630`
* But the configured origin is `http://192.168.3.2:15630`
→ **CORS will fail**, and you can immediately see why.
#### When Should You Configure CORS?
It is strongly recommended to configure CORS in production environments to ensure that only trusted browser origins can access Bichon.
If you want to access Bichon from a browser:
* Add the exact **IP** with port
* Or the exact **hostname** with port
* Or the **domain** (port optional if it's 80)
Examples:
```
http://192.168.1.16:15630
http://myserver.local:15630
http://mydomain.com
```
If you access Bichon in **multiple different ways**, list all of them:
```
-e BICHON_CORS_ORIGINS="http://192.168.1.16:15630,http://myserver.local:15630,http://mydomain.com"
```
> **Do not add a trailing slash**
> (`http://192.168.1.16:15630/` will not match)
>
> **Do not use `*`**, it is not supported.
#### How to Enable Debug Logs (Highly Recommended for CORS Issues)
Set environment variable:
```
BICHON_LOG_LEVEL=debug
```
Or via command-line:
```
--bichon-log-level debug
```
Default is `info`, so CORS logs will not appear unless debug logging is enabled.
---
#### ⚠️ Note on Running Bichon in a Container
> ⚠️ **Note:** If you are running Bichon in a container (via **Docker Compose** or **docker run**), be careful with **quotes in environment variable values**.
For example, **do not** write:
```bash
docker run -d \
--name bichon \
-p 15630:15630 \
-v $(pwd)/bichon-data:/data \
-e BICHON_LOG_LEVEL=info \
-e BICHON_ROOT_DIR=/data \
-e BICHON_CORS_ORIGINS="http://192.168.1.16:15630,http://myserver.local:15630,http://mydomain.com" \
rustmailer/bichon:latest
-e BICHON_CORS_ORIGINS="http://localhost:15630,http://myserver.local:15630"
```
> **Tip:** Do not add a trailing `/`. Using `*` allows all addresses, but is **not recommended** for security.
* The outer quotes (`"`) will be passed literally into the container and may cause CORS misconfiguration.
**Correct way:**
```bash
-e BICHON_CORS_ORIGINS=http://localhost:15630,http://myserver.local:15630
```
Or using YAML literal style for Docker Compose:
```yaml
environment:
BICHON_CORS_ORIGINS: |
http://localhost:15630,http://myserver.local:15630
```
This ensures that the configured origins are interpreted correctly inside the container.
> ⚠️ **Note:** This fucking problem I actually didnt know about myself; thanks to [gall-1](https://github.com/gall-1) for pointing it out.
### Binary Deployment
@@ -289,6 +364,13 @@ You can change the password via the WebUI:
> Under construction. Documentation will be available soon.
[Bichon Wiki](https://github.com/rustmailer/bichon/wiki).
## FAQ
please see the FAQ in the project Wiki:
👉 [https://github.com/rustmailer/bichon/wiki/FAQ](https://github.com/rustmailer/bichon/wiki/FAQ-(Frequently-Asked-Questions))
## 🛠️ Tech Stack
- **Backend**: Rust + Poem
@@ -304,7 +386,7 @@ You can change the password via the WebUI:
Contributions of all kinds are welcome!
Whether youd like to submit code, report a bug, or share practical suggestions that can help improve the project, your input is highly appreciated.
Feel free to open an Issue or a Pull Request anytime. You can also reach out on Discord if youd like to discuss ideas or improvements.
<a href="https://discord.gg/evFnSpdpaE">
<a href="https://discord.gg/Bq4M2cDmF4">
<img src="https://img.shields.io/badge/Discord-Join%20Server-7289DA?logo=discord&logoColor=white" alt="Discord">
</a>
@@ -370,7 +452,7 @@ This project is licensed under [AGPLv3](LICENSE).
- [Docker Hub](https://hub.docker.com/r/rustmailer/bichon)
- [Issue Tracker](https://github.com/rustmailer/bichon/issues)
- [Discord](https://discord.gg/evFnSpdpaE)
- [Discord](https://discord.gg/Bq4M2cDmF4)
## 💖 Support & Promotion

View File

@@ -69,7 +69,7 @@ impl AccountRunningState {
errors: vec![],
is_initial_sync_completed: false,
progress: None,
initial_sync_start_time: None,
initial_sync_start_time: Some(utc_now!()),
initial_sync_end_time: None,
initial_sync_failed_time: None,
};
@@ -125,14 +125,14 @@ impl AccountRunningState {
.await
}
pub async fn set_initial_sync_start(account_id: u64) -> BichonResult<()> {
Self::update_account_running_state(account_id, move |current| {
let mut updated = current.clone();
updated.initial_sync_start_time = Some(utc_now!());
Ok(updated)
})
.await
}
// pub async fn set_initial_sync_start(account_id: u64) -> BichonResult<()> {
// Self::update_account_running_state(account_id, move |current| {
// let mut updated = current.clone();
// updated.initial_sync_start_time = Some(utc_now!());
// Ok(updated)
// })
// .await
// }
pub async fn set_initial_sync_completed(account_id: u64) -> BichonResult<()> {
Self::update_account_running_state(account_id, move |current| {

View File

@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{
modules::{
account::{
@@ -46,13 +45,13 @@ pub async fn execute_imap_sync(account: &AccountModel) -> BichonResult<()> {
let start_time = Instant::now();
let account_id = account.id;
let sync_type = determine_sync_type(account).await?;
if matches!(sync_type, SyncType::SkipSync) {
return Ok(());
}
let remote_mailboxes = get_sync_folders(account).await?;
if matches!(sync_type, SyncType::InitialSync) {
AccountRunningState::set_initial_sync_start(account_id).await?;
AccountRunningState::add(account.id).await?;
// AccountRunningState::set_initial_sync_start(account_id).await?;
let result = match &account.date_since {
Some(date_since) => {
rebuild_cache_since_date(account, &remote_mailboxes, date_since).await

View File

@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{
modules::{
account::{migration::AccountModel, state::AccountRunningState},
@@ -51,10 +50,7 @@ pub async fn determine_sync_type(account: &AccountModel) -> BichonResult<SyncTyp
SyncType::SkipSync
}
}
None => {
AccountRunningState::add(account.id).await?;
SyncType::InitialSync
}
None => SyncType::InitialSync,
})
}

View File

@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::common::error::ErrorCapture;
use crate::modules::common::log::Tracing;
use crate::modules::common::tls::rustls_config;
@@ -33,13 +32,14 @@ use crate::modules::common::timeout::{Timeout, TIMEOUT_HEADER};
use crate::raise_error;
use api::create_openapi_service;
use assets::FrontEndAssets;
use http::HeaderValue;
use http::{HeaderValue, Method};
use poem::endpoint::EmbeddedFilesEndpoint;
use poem::listener::{Listener, TcpListener};
use poem::middleware::{CatchPanic, Compression, SetHeader};
use poem::{endpoint::EmbeddedFileEndpoint, middleware::Cors, EndpointExt, Route, Server};
use poem::{get, post};
use public::oauth2::oauth2_callback;
use std::collections::HashSet;
use std::time::Duration;
pub mod api;
@@ -62,7 +62,7 @@ pub async fn start_http_server() -> BichonResult<()> {
};
let api_service = create_openapi_service()
.summary("A self-hosted IMAP/SMTP middleware designed for developers");
.summary("A lightweight, high-performance Rust email archiver with WebUI");
let swagger = api_service.swagger_ui();
let redoc = api_service.redoc();
@@ -78,10 +78,35 @@ pub async fn start_http_server() -> BichonResult<()> {
.with(Timeout)
.with(Tracing);
let mut cors_origins = SETTINGS.bichon_cors_origins.clone();
if cors_origins.is_empty() {
cors_origins = ["*".to_string()].into_iter().collect();
}
let cors_origins: Option<HashSet<String>> =
SETTINGS.bichon_cors_origins.clone();
let cors_origins: Vec<String> = cors_origins.unwrap_or_default().into_iter().collect();
let cors = Cors::new()
.allow_origins_fn(move |origin| {
tracing::debug!("CORS: Incoming Origin = {:?}", origin);
tracing::debug!("CORS: Configured origins = {:?}", cors_origins);
if cors_origins.is_empty() {
tracing::debug!("CORS: No origins configured, allowing all");
return true;
}
cors_origins.iter().any(|o| o == origin)
})
//.allow_origins(cors_origins)
.allow_credentials(true)
.allow_methods(&[
Method::GET,
Method::POST,
Method::PUT,
Method::DELETE,
Method::OPTIONS,
Method::HEAD,
Method::PATCH,
])
.allow_headers(vec!["Content-Type", "Authorization", TIMEOUT_HEADER])
.expose_headers(vec!["Accept"])
.max_age(SETTINGS.bichon_cors_max_age);
let cache_static = || {
SetHeader::new().overriding(
@@ -90,14 +115,6 @@ pub async fn start_http_server() -> BichonResult<()> {
)
};
let cors = Cors::new()
.allow_origins(cors_origins)
.allow_credentials(true)
.allow_methods(vec!["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD"])
.allow_headers(vec!["Content-Type", "Authorization", TIMEOUT_HEADER])
.expose_headers(vec!["Accept"])
.max_age(SETTINGS.bichon_cors_max_age);
let route = Route::new()
.nest("/api-docs/swagger", swagger)
.nest("/api-docs/redoc", redoc)

View File

@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use clap::{builder::ValueParser, Parser, ValueEnum};
use std::{collections::HashSet, env, fmt, path::PathBuf, sync::LazyLock};
@@ -77,7 +76,6 @@ pub struct Settings {
/// CORS allowed origins (default: "*")
#[clap(
long,
default_value = "http://localhost:5173, http://localhost:15630, *",
env,
help = "Set the allowed CORS origins (comma-separated list, e.g., \"https://example.com, https://another.com\")",
value_parser = ValueParser::new(|s: &str| -> Result<HashSet<String>, String> {
@@ -88,7 +86,7 @@ pub struct Settings {
Ok(set)
})
)]
pub bichon_cors_origins: HashSet<String>,
pub bichon_cors_origins: Option<HashSet<String>>,
/// CORS max age in seconds (default: 86400)
#[clap(

View File

@@ -139,7 +139,7 @@ export type Steps = [
const getSteps = (t: (key: string) => string): Steps => [
{ id: "step-1", name: t('accounts.steps.emailAddress'), fields: ["email"] },
{ id: "step-2", name: t('accounts.steps.imap'), fields: ["imap", "use_dangerous"] },
{ id: "step-2", name: t('accounts.steps.imap'), fields: ["imap", "use_dangerous", "name"] },
{ id: "step-3", name: t('accounts.steps.syncPreferences'), fields: ["enabled", "date_since", "folder_limit", "sync_interval_min"] },
{ id: "step-4", name: t('accounts.steps.summary'), fields: [] },
];

View File

@@ -66,22 +66,6 @@ export default function Step1({ isEdit }: StepProps) {
</FormItem>
)}
/>
<FormField
control={control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">
{t('accounts.name')}:
</FormLabel>
<FormControl>
<Input placeholder={t('accounts.namePlaceholder')} {...field} />
</FormControl>
<FormDescription>{t('accounts.nameDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</>
);

View File

@@ -128,6 +128,22 @@ export default function Step2({ isEdit }: StepProps) {
</FormItem>
)}
/>
<FormField
control={control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">
{t('accounts.name')}:
</FormLabel>
<FormControl>
<Input placeholder={t('accounts.namePlaceholder')} {...field} />
</FormControl>
<FormDescription>{t('accounts.nameDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="imap.auth.auth_type"