mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-31 01:52:30 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1453f3cda | ||
|
|
9e656b1f91 | ||
|
|
afac192280 | ||
|
|
c00ffe8d11 | ||
|
|
c90a2d552d | ||
|
|
a4627a63cb | ||
|
|
684b9765fe | ||
|
|
b0ce7671a8 | ||
|
|
4b13bf14c4 |
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "bichon"
|
||||
version = "0.1.3"
|
||||
version = "0.1.4"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
|
||||
140
README.md
140
README.md
@@ -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 didn’t 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 you’d 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 you’d 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
|
||||
|
||||
@@ -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| {
|
||||
|
||||
5
src/modules/cache/imap/sync/mod.rs
vendored
5
src/modules/cache/imap/sync/mod.rs
vendored
@@ -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
|
||||
|
||||
6
src/modules/cache/imap/sync/sync_type.rs
vendored
6
src/modules/cache/imap/sync/sync_type.rs
vendored
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: [] },
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user