14 Commits
0.1.2 ... 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
rustmailer
0015192b4d bump version to 0.1.3 2025-12-03 09:27:17 +08:00
rustmailer
2f3acdd759 fix(ui, config): Ensure use_dangerous status is visible in ui 2025-12-03 09:26:34 +08:00
rustmailer
0fc83b693e fix(i18n, dashboard): Internationalize recent activity chart dates 2025-12-03 09:23:51 +08:00
rustmailer
9ba56ca5bb feat(i18n): Implement internationalization for date distance 2025-12-03 09:23:16 +08:00
rustmailer
8f7244ccb9 fix(account): Resolve name clearing issue and update field labels 2025-12-03 09:21:33 +08:00
40 changed files with 362 additions and 169 deletions

2
Cargo.lock generated
View File

@@ -424,7 +424,7 @@ dependencies = [
[[package]]
name = "bichon"
version = "0.1.2"
version = "0.1.3"
dependencies = [
"ahash",
"async-imap",

View File

@@ -1,6 +1,6 @@
[package]
name = "bichon"
version = "0.1.2"
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

@@ -349,7 +349,11 @@ impl AccountV2 {
}
if let Some(name) = &request.name {
new.name = Some(name.clone());
if name.trim().is_empty() {
new.name = None;
} else {
new.name = Some(name.clone());
}
}
if matches!(old.account_type, AccountType::IMAP) {

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

@@ -23,11 +23,11 @@ import LongText from '@/components/long-text'
import { AccessToken } from '../data/schema'
import { DataTableColumnHeader } from './data-table-column-header'
import { DataTableRowActions } from './data-table-row-actions'
import { format, formatDistanceToNow } from 'date-fns'
import { format, formatDistanceToNow, Locale } from 'date-fns'
import { AccountCellAction } from './account-action'
import { AclCellAction } from './acl-action'
export const getColumns = (t: (key: string) => string): ColumnDef<AccessToken>[] => [
export const getColumns = (t: (key: string) => string, locale: Locale): ColumnDef<AccessToken>[] => [
{
accessorKey: 'token',
header: ({ column }) => (
@@ -114,7 +114,7 @@ export const getColumns = (t: (key: string) => string): ColumnDef<AccessToken>[]
if (last_access_at === 0) {
return <LongText className='max-w-40'>{t('accessTokens.notUsedYet')}</LongText>;
}
const result = formatDistanceToNow(new Date(last_access_at), { addSuffix: true });
const result = formatDistanceToNow(new Date(last_access_at), { addSuffix: true, locale });
return <LongText className='max-w-40'>{result}</LongText>;
},
meta: { className: 'w-40' },

View File

@@ -38,9 +38,12 @@ import { list_access_tokens } from '@/api/access-tokens/api'
import { TableSkeleton } from '@/components/table-skeleton'
import { FixedHeader } from '@/components/layout/fixed-header'
import { useTranslation } from 'react-i18next'
import { dateFnsLocaleMap } from '@/lib/utils'
import { enUS } from 'date-fns/locale'
export default function AccessTokens() {
const { t } = useTranslation()
const { t, i18n } = useTranslation()
const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS;
// Dialog states
const [currentRow, setCurrentRow] = useState<AccessToken | null>(null)
const [open, setOpen] = useDialogState<AccessTokensDialogType>(null)
@@ -50,7 +53,7 @@ export default function AccessTokens() {
queryFn: list_access_tokens,
})
const columns = getColumns(t)
const columns = getColumns(t, locale)
return (
<AccessTokensProvider value={{ open, setOpen, currentRow, setCurrentRow }}>
@@ -58,43 +61,43 @@ export default function AccessTokens() {
<FixedHeader />
<Main>
<div className="mx-auto mb-2 flex max-w-5xl flex-wrap items-center justify-between gap-x-4 gap-y-2 px-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">{t('accessTokens.title')}</h2>
<p className="text-muted-foreground">
{t('accessTokens.description')}
</p>
</div>
<div className="flex gap-2">
<Button className="space-x-1" onClick={() => setOpen('add')}>
<span>{t('common.add')}</span> <Plus size={18} />
</Button>
</div>
</div>
<div className="mx-auto flex-1 overflow-auto px-4 py-1 flex-row lg:space-x-12 space-y-0 max-w-5xl">
{isLoading ? (
<TableSkeleton columns={columns.length} rows={10} />
) : accessTokens?.length ? (
<AccessTokensTable data={accessTokens} columns={columns} />
) : (
<div className="flex h-[450px] shrink-0 items-center justify-center rounded-md border border-dashed">
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center">
<img
src={Logo}
className="max-h-[100px] w-auto opacity-20 saturate-0 transition-all duration-300 hover:opacity-100 hover:saturate-100 object-contain"
alt="Bichon Logo"
/>
<h3 className="mt-4 text-lg font-semibold">{t('accessTokens.noTokens')}</h3>
<p className="mb-4 mt-2 text-sm text-muted-foreground">
{t('accessTokens.noTokensDesc')}
</p>
<Button onClick={() => setOpen('add')}>{t('accessTokens.create')}</Button>
<div className="mx-auto mb-2 flex max-w-5xl flex-wrap items-center justify-between gap-x-4 gap-y-2 px-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">{t('accessTokens.title')}</h2>
<p className="text-muted-foreground">
{t('accessTokens.description')}
</p>
</div>
<div className="flex gap-2">
<Button className="space-x-1" onClick={() => setOpen('add')}>
<span>{t('common.add')}</span> <Plus size={18} />
</Button>
</div>
</div>
</div>
)}
</div>
</Main>
<div className="mx-auto flex-1 overflow-auto px-4 py-1 flex-row lg:space-x-12 space-y-0 max-w-5xl">
{isLoading ? (
<TableSkeleton columns={columns.length} rows={10} />
) : accessTokens?.length ? (
<AccessTokensTable data={accessTokens} columns={columns} />
) : (
<div className="flex h-[450px] shrink-0 items-center justify-center rounded-md border border-dashed">
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center">
<img
src={Logo}
className="max-h-[100px] w-auto opacity-20 saturate-0 transition-all duration-300 hover:opacity-100 hover:saturate-100 object-contain"
alt="Bichon Logo"
/>
<h3 className="mt-4 text-lg font-semibold">{t('accessTokens.noTokens')}</h3>
<p className="mb-4 mt-2 text-sm text-muted-foreground">
{t('accessTokens.noTokensDesc')}
</p>
<Button onClick={() => setOpen('add')}>{t('accessTokens.create')}</Button>
</div>
</div>
)}
</div>
</Main>
<TokensActionDialog

View File

@@ -120,6 +120,10 @@ export function AccountDetailDrawer({ open, onOpenChange, currentRow }: Props) {
<span className="text-muted-foreground">{t('accounts.encryption')}:</span>
<span>{currentRow.imap?.encryption}</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">{t('accounts.useDangerous')}:</span>
<span>{`${currentRow.use_dangerous}`}</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">{t('accounts.auth')}:</span>
{currentRow.imap?.auth.auth_type === "OAuth2" ? (

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

@@ -42,6 +42,8 @@ import { IconCopy } from '@tabler/icons-react'
import { toast } from '@/hooks/use-toast'
import { ToastAction } from '@/components/ui/toast'
import { useNavigate } from '@tanstack/react-router'
import { dateFnsLocaleMap } from '@/lib/utils'
import { enUS } from 'date-fns/locale'
interface Props {
currentRow: AccountModel
@@ -50,7 +52,8 @@ interface Props {
}
export function OAuth2TokensDialog({ currentRow, open, onOpenChange }: Props) {
const { t } = useTranslation()
const { t, i18n } = useTranslation()
const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS;
const navigate = useNavigate()
const { data: oauth2Tokens, isLoading } = useQuery({
queryKey: ['oauth2-tokens', currentRow.id],
@@ -151,7 +154,7 @@ export function OAuth2TokensDialog({ currentRow, open, onOpenChange }: Props) {
<TableRow>
<TableCell className='max-w-80'>{t('settings.updatedAt')}</TableCell>
<TableCell>
{formatDistanceToNow(new Date(oauth2Tokens.updated_at), { addSuffix: true })}
{formatDistanceToNow(new Date(oauth2Tokens.updated_at), { addSuffix: true, locale })}
</TableCell>
</TableRow>
</TableBody>

View File

@@ -34,6 +34,8 @@ import { Skeleton } from '@/components/ui/skeleton'
import { CheckCircle, Clock, Loader2, PlayCircle, FolderSync, FolderCheck } from 'lucide-react'
import { FolderSyncProgress } from './folder-sync-progress'
import { useTranslation } from 'react-i18next'
import { dateFnsLocaleMap } from '@/lib/utils'
import { enUS } from 'date-fns/locale'
interface Props {
open: boolean
@@ -42,7 +44,8 @@ interface Props {
}
export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
const { t } = useTranslation()
const { t, i18n } = useTranslation()
const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS;
const { data: state, isLoading } = useQuery({
queryKey: ['running-state', currentRow.id],
queryFn: () => account_state(currentRow.id),
@@ -124,7 +127,7 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
<span className="font-medium">
{state.initial_sync_start_time ? (
<span className="text-green-600">
{formatDistanceToNow(new Date(state.initial_sync_start_time), { addSuffix: true })}
{formatDistanceToNow(new Date(state.initial_sync_start_time), { addSuffix: true, locale })}
</span>
) : (
<span className="flex items-center gap-1 text-yellow-600">
@@ -143,7 +146,7 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
<span className="font-medium">
{state.initial_sync_end_time ? (
<span className="text-green-600">
{formatDistanceToNow(new Date(state.initial_sync_end_time), { addSuffix: true })}
{formatDistanceToNow(new Date(state.initial_sync_end_time), { addSuffix: true, locale })}
</span>
) : state.initial_sync_start_time ? (
<span className="flex items-center gap-1 text-blue-600">
@@ -192,7 +195,7 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
<span className="text-muted-foreground">{t('accounts.runningState.startTime')}</span>
<span className="font-medium">
{state.last_incremental_sync_start ? (
formatDistanceToNow(new Date(state.last_incremental_sync_start), { addSuffix: true })
formatDistanceToNow(new Date(state.last_incremental_sync_start), { addSuffix: true, locale })
) : (
<span className="text-yellow-600">{t('accounts.runningState.notStarted')}</span>
)}
@@ -202,7 +205,7 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
<span className="text-muted-foreground">{t('accounts.runningState.endTime')}</span>
<span className="font-medium">
{state.last_incremental_sync_end ? (
formatDistanceToNow(new Date(state.last_incremental_sync_end), { addSuffix: true })
formatDistanceToNow(new Date(state.last_incremental_sync_end), { addSuffix: true, locale })
) : state.last_incremental_sync_start ? (
<span className="text-blue-600">{t('accounts.runningState.inProgress')}</span>
) : (
@@ -243,7 +246,7 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
>
<div className="flex w-full flex-col gap-1">
<div className="text-xs font-medium text-muted-foreground">
{formatDistanceToNow(new Date(item.at), { addSuffix: true })}
{formatDistanceToNow(new Date(item.at), { addSuffix: true, locale })}
</div>
<div className="font-medium break-words">{item.error}</div>
</div>

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"

View File

@@ -58,6 +58,10 @@ export default function Step4() {
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">{t('accounts.encryption')}:</td>
<td className="px-6 py-2 whitespace-nowrap text-sm">{summaryData.imap.encryption}</td>
</tr>
<tr>
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">{t('accounts.useDangerous')}:</td>
<td className="px-6 py-2 whitespace-nowrap text-sm">{`${summaryData.use_dangerous}`}</td>
</tr>
<tr>
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">{t('accounts.authType')}:</td>
<td className="px-6 py-2 whitespace-nowrap text-sm">{summaryData.imap.auth.auth_type}</td>

View File

@@ -35,18 +35,31 @@ interface DailyActivity {
count: number;
}
function convertRecentActivity(timeBuckets: TimeBucket[]): DailyActivity[] {
function convertRecentActivity(timeBuckets: TimeBucket[], locale: string): DailyActivity[] {
const dateFormatter = new Intl.DateTimeFormat(locale, {
month: 'short',
day: 'numeric',
});
return timeBuckets.map(bucket => {
const date = new Date(bucket.timestamp_ms);
const mm = String(date.getMonth() + 1).padStart(2, '0');
const dd = String(date.getDate()).padStart(2, '0');
return {
date: `${mm}-${dd}`,
date: dateFormatter.format(date),
count: bucket.count,
timestamp_ms: bucket.timestamp_ms,
};
});
}
const formatTooltipDate = (timestamp_ms: number, locale: string): string => {
const date = new Date(timestamp_ms);
return new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(date);
};
const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444'];
// Skeleton Components
@@ -94,7 +107,10 @@ export default function MailArchiveDashboard() {
queryFn: get_dashboard_stats,
});
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const currentLocale = i18n.resolvedLanguage || i18n.language || navigator.language;
const totalAttachments = (stats?.with_attachment_count ?? 0) + (stats?.without_attachment_count ?? 0);
const attachmentRatio = totalAttachments > 0 ? (stats?.with_attachment_count ?? 0) / totalAttachments : 0;
@@ -253,12 +269,25 @@ export default function MailArchiveDashboard() {
<CardContent className="h-80">
{hasRecentActivity ? (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={convertRecentActivity(stats!.recent_activity)} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<BarChart data={convertRecentActivity(stats!.recent_activity, currentLocale)} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
<XAxis dataKey="date" tick={{ fontSize: 12 }} interval="preserveStart" tickCount={10} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip
formatter={(v) => formatNumber(v as number)}
content={({ payload }) => {
if (payload && payload.length) {
const dataPoint = payload[0].payload;
const fullDate = formatTooltipDate(dataPoint.timestamp_ms, currentLocale);
return (
<div className="p-2 border rounded-lg shadow-md bg-white dark:bg-gray-800">
<p className="font-semibold text-sm mb-1">{fullDate}</p>
<p className="text-xs">{t('dashboard.emails')}: {formatNumber(dataPoint.count)}</p>
</div>
);
}
return null;
}}
contentStyle={{
backgroundColor: 'hsl(var(--background))',
border: '1px solid hsl(var(--border))',

View File

@@ -22,6 +22,7 @@ import useMinimalAccountList from "@/hooks/use-minimal-account-list";
import { VirtualizedSelect } from "@/components/virtualized-select";
import { Button } from "@/components/ui/button";
import { useNavigate } from "@tanstack/react-router";
import { useTranslation } from "react-i18next";
interface AccountSwitcherProps {
@@ -35,7 +36,7 @@ export function AccountSwitcher({
}: AccountSwitcherProps) {
const { accountsOptions, isLoading } = useMinimalAccountList();
const navigate = useNavigate()
const { t } = useTranslation();
if (isLoading) {
return <div>Loading...</div>;
}
@@ -47,7 +48,7 @@ export function AccountSwitcher({
options={accountsOptions}
defaultValue={`${defaultAccountId}`}
onSelectOption={(values) => onAccountSelect(parseInt(values[0], 10))}
placeholder="Select an account"
placeholder={t('oauth2.selectAnAccount')}
noItemsComponent={<div className='space-y-2'>
<p>No active email account.</p>
<Button variant={'outline'} className="py-1 px-3 text-xs" onClick={() => navigate({ to: '/accounts' })}>Add Email Account</Button>

View File

@@ -17,7 +17,7 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { cn, formatBytes } from "@/lib/utils"
import { cn, dateFnsLocaleMap, formatBytes } from "@/lib/utils"
import { formatDistanceToNow } from "date-fns"
import { MailIcon, Paperclip, Trash2 } from "lucide-react"
import { Skeleton } from "@/components/ui/skeleton"
@@ -27,6 +27,7 @@ import { Checkbox } from "@/components/ui/checkbox"
import { MailBulkActions } from "./bulk-actions"
import { Badge } from "@/components/ui/badge"
import { useTranslation } from 'react-i18next'
import { enUS } from "date-fns/locale"
interface MailListProps {
items: EmailEnvelope[]
@@ -37,8 +38,9 @@ export function MailList({
items,
isLoading,
}: MailListProps) {
const { t } = useTranslation()
const { t, i18n } = useTranslation()
const { currentEnvelope, setCurrentEnvelope, setDeleteIds, setOpen, selected, setSelected } = useMailboxContext()
const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS;
const handleDelete = (envelope: EmailEnvelope) => {
setDeleteIds(new Set([envelope.id]))
@@ -177,7 +179,7 @@ export function MailList({
<span className={cn(
isSelected ? "text-foreground font-medium" : "text-muted-foreground"
)}>
{item.date && formatDistanceToNow(new Date(item.date), { addSuffix: true })}
{item.date && formatDistanceToNow(new Date(item.date), { addSuffix: true, locale })}
</span>
<button

View File

@@ -17,7 +17,7 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { cn, formatBytes } from "@/lib/utils"
import { cn, dateFnsLocaleMap, formatBytes } from "@/lib/utils"
import { formatDistanceToNow } from "date-fns"
import { MailIcon, MoreVertical, Paperclip, TagIcon, Trash2 } from "lucide-react"
import { Skeleton } from "@/components/ui/skeleton"
@@ -29,6 +29,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { useTranslation } from 'react-i18next'
import { enUS } from "date-fns/locale"
interface MailListProps {
items: EmailEnvelope[]
@@ -41,7 +42,9 @@ export function MailList({
isLoading,
onEnvelopeChanged
}: MailListProps) {
const { t } = useTranslation()
const { t, i18n } = useTranslation()
const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS;
const { setOpen, currentEnvelope, setCurrentEnvelope, selected, setSelected, setToDelete } = useSearchContext()
const handleToggleAll = () => {
@@ -212,7 +215,7 @@ export function MailList({
<span className="hidden md:inline">{formatBytes(item.size)}</span>
<span className={cn(isSelectedRow ? "text-foreground font-medium" : "text-muted-foreground")}>
{item.date && formatDistanceToNow(new Date(item.date), { addSuffix: true })}
{item.date && formatDistanceToNow(new Date(item.date), { addSuffix: true, locale })}
</span>
<DropdownMenu>

View File

@@ -16,7 +16,7 @@
// 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/>.
import { enUS, zhCN, zhTW, arSA, de, es, fi, fr, it, ja, ko, nl, ptBR, ru, da, sv, nb, Locale } from 'date-fns/locale';
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
@@ -70,8 +70,12 @@ export function mapToRecordOfArrays(
);
}
export function formatNumber(num: number) {
return new Intl.NumberFormat('en-US').format(num);
export function formatNumber(num: number): string {
const userLocale = navigator.language;
return new Intl.NumberFormat(userLocale, {
maximumFractionDigits: 2,
}).format(num);
}
@@ -126,4 +130,45 @@ export function formatTimestamp(milliseconds: number): string {
const offsetHours = String(Math.floor(Math.abs(timezoneOffset) / 60)).padStart(2, '0');
const offsetMinutes = String(Math.abs(timezoneOffset) % 60).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}${offsetSign}${offsetHours}:${offsetMinutes}`;
}
}
// i18n.language -> date-fns locale
export const dateFnsLocaleMap: Record<string, Locale> = {
en: enUS,
'en-us': enUS,
zh: zhCN,
'zh-cn': zhCN,
'zh-tw': zhTW,
'zh_hk': zhTW,
ar: arSA,
'ar-sa': arSA,
de: de,
'de-de': de,
es: es,
'es-es': es,
fi: fi,
'fi-fi': fi,
fr: fr,
'fr-fr': fr,
it: it,
'it-it': it,
jp: ja,
ja: ja,
'ja-jp': ja,
ko: ko,
'ko-kr': ko,
nl: nl,
'nl-nl': nl,
pt: ptBR,
'pt-br': ptBR,
ru: ru,
'ru-ru': ru,
da: da,
'da-dk': da,
sv: sv,
'sv-se': sv,
no: nb,
'no-no': nb,
};

View File

@@ -138,7 +138,7 @@
"noAccountConfigurations": "لا توجد تكوينات للحساب",
"noAccountConfigurationsDesc": "لم تقم بإضافة أي تكوينات للحساب بعد. أضف واحدة لبدء استخدام ميزات الحساب.",
"addConfiguration": "إضافة تكوين",
"name": "الاسم",
"name": "اسم الدخول",
"email": "البريد الإلكتروني",
"status": "الحالة",
"type": "النوع",

View File

@@ -138,7 +138,7 @@
"noAccountConfigurations": "Ingen kontokonfigurationer",
"noAccountConfigurationsDesc": "Du har ikke tilføjet nogen kontokonfigurationer endnu. Tilføj en for at begynde at bruge kontofunktioner.",
"addConfiguration": "Tilføj konfiguration",
"name": "Navn",
"name": "Logindnavn",
"email": "E-mail",
"status": "Status",
"type": "Type",

View File

@@ -138,7 +138,7 @@
"noAccountConfigurations": "Keine Kontokonfigurationen",
"noAccountConfigurationsDesc": "Sie haben noch keine Kontokonfigurationen hinzugefügt. Fügen Sie eine hinzu, um mit der Nutzung der Kontofunktionen zu beginnen.",
"addConfiguration": "Konfiguration hinzufügen",
"name": "Name",
"name": "Anmeldename",
"email": "E-Mail",
"status": "Status",
"type": "Typ",

View File

@@ -138,7 +138,7 @@
"noAccountConfigurations": "No Account Configurations",
"noAccountConfigurationsDesc": "You haven't added any Account configurations yet. Add one to start using Account features.",
"addConfiguration": "Add Configuration",
"name": "Name",
"name": "Login Name",
"email": "Email",
"status": "Status",
"type": "Type",

View File

@@ -138,7 +138,7 @@
"noAccountConfigurations": "Sin configuraciones de cuenta",
"noAccountConfigurationsDesc": "Aún no has añadido ninguna configuración de cuenta. Añade una para empezar a usar las funcionalidades de la cuenta.",
"addConfiguration": "Añadir configuración",
"name": "Nombre",
"name": "Nombre de usuario",
"email": "Correo electrónico",
"status": "Estado",
"type": "Tipo",

View File

@@ -138,7 +138,7 @@
"noAccountConfigurations": "Ei tilimäärityksiä",
"noAccountConfigurationsDesc": "Et ole vielä lisännyt tilimäärityksiä. Lisää yksi aloittaaksesi tilitoimintojen käytön.",
"addConfiguration": "Lisää määritys",
"name": "Nimi",
"name": "Kirjautumisnimi",
"email": "Sähköposti",
"status": "Tila",
"type": "Tyyppi",

View File

@@ -138,7 +138,7 @@
"noAccountConfigurations": "Aucune Configuration de Compte",
"noAccountConfigurationsDesc": "Vous n'avez pas encore ajouté de Configuration de Compte. Veuillez en ajouter une pour commencer à utiliser les fonctionnalités du Compte.",
"addConfiguration": "Ajouter Configuration",
"name": "Nom",
"name": "Nom de connexion",
"email": "E-mail",
"status": "Statut",
"type": "Type",

View File

@@ -138,7 +138,7 @@
"noAccountConfigurations": "Nessuna Configurazione Account",
"noAccountConfigurationsDesc": "Non hai ancora aggiunto alcuna Configurazione Account. Aggiungine una per iniziare a usare le funzionalità Account.",
"addConfiguration": "Aggiungi Configurazione",
"name": "Nome",
"name": "Nome di accesso",
"email": "Email",
"status": "Stato",
"type": "Tipo",

View File

@@ -138,7 +138,7 @@
"noAccountConfigurations": "アカウント設定がありません",
"noAccountConfigurationsDesc": "まだアカウント設定を追加していません。機能を利用するには設定を追加してください。",
"addConfiguration": "設定を追加",
"name": "名",
"name": "ログイン名",
"email": "メールアドレス",
"status": "ステータス",
"type": "タイプ",

View File

@@ -138,7 +138,7 @@
"noAccountConfigurations": "계정 구성 없음",
"noAccountConfigurationsDesc": "아직 계정 구성을 추가하지 않았습니다. 기능을 사용하려면 추가하십시오.",
"addConfiguration": "구성 추가",
"name": "이름",
"name": "로그인 이름",
"email": "이메일",
"status": "상태",
"type": "유형",

View File

@@ -138,7 +138,7 @@
"noAccountConfigurations": "Geen Accountconfiguraties",
"noAccountConfigurationsDesc": "U heeft nog geen Accountconfiguraties toegevoegd. Voeg er een toe om de Accountfuncties te gebruiken.",
"addConfiguration": "Configuratie Toevoegen",
"name": "Naam",
"name": "Inlognaam",
"email": "E-mail",
"status": "Status",
"type": "Type",

View File

@@ -138,7 +138,7 @@
"noAccountConfigurations": "Ingen kontokonfigurasjoner",
"noAccountConfigurationsDesc": "Du har ikke lagt til noen kontokonfigurasjoner ennå. Legg til en for å begynne å bruke kontofunksjoner.",
"addConfiguration": "Legg til konfigurasjon",
"name": "Navn",
"name": "Påloggingsnavn",
"email": "E-post",
"status": "Status",
"type": "Type",

View File

@@ -138,7 +138,7 @@
"noAccountConfigurations": "Nenhuma Configuração de Conta",
"noAccountConfigurationsDesc": "Você ainda não adicionou nenhuma configuração de conta. Adicione uma para utilizar a funcionalidade.",
"addConfiguration": "Adicionar Configuração",
"name": "Nome",
"name": "Nome de login",
"email": "Email",
"status": "Status",
"type": "Tipo",

View File

@@ -138,7 +138,7 @@
"noAccountConfigurations": "Нет настроек учетных записей",
"noAccountConfigurationsDesc": "Вы еще не добавили ни одной конфигурации учетной записи. Добавьте одну, чтобы начать использовать функции аккаунта.",
"addConfiguration": "Добавить конфигурацию",
"name": "Имя",
"name": "Имя для входа",
"email": "Email",
"status": "Статус",
"type": "Тип",

View File

@@ -138,7 +138,7 @@
"noAccountConfigurations": "Inga kontokonfigurationer",
"noAccountConfigurationsDesc": "Du har inte lagt till några kontokonfigurationer ännu. Lägg till en för att börja använda kontofunktioner.",
"addConfiguration": "Lägg till konfiguration",
"name": "Namn",
"name": "Inloggningsnamn",
"email": "E-post",
"status": "Status",
"type": "Typ",

View File

@@ -138,7 +138,7 @@
"noAccountConfigurations": "沒有帳號設定",
"noAccountConfigurationsDesc": "您尚未新增任何帳號設定。請新增設定以使用功能。",
"addConfiguration": "新增設定",
"name": "名稱",
"name": "登入名稱",
"email": "電子郵件",
"status": "狀態",
"type": "類型",

View File

@@ -138,7 +138,7 @@
"noAccountConfigurations": "无账户配置",
"noAccountConfigurationsDesc": "您尚未添加任何账户配置。添加一个以开始使用账户功能。",
"addConfiguration": "添加配置",
"name": "名",
"name": "登录名",
"email": "邮箱",
"status": "状态",
"type": "类型",