feat: Improve account setup UI and add post-download email filtering

This commit is contained in:
rustmailer
2026-06-30 09:42:03 +08:00
parent 5fe45795b9
commit 2da42134d0
42 changed files with 3265 additions and 1670 deletions
+15
View File
@@ -118,6 +118,20 @@ interface DateSelection {
export type QuotaWindow = 'hourly' | 'daily' | 'weekly' | 'monthly'
export interface FilterRule {
include: string[];
exclude: string[];
}
export interface ArchiveRules {
enabled: boolean;
senders: FilterRule;
subjects: FilterRule;
skip_larger_than?: number;
spam_headers: string[];
}
export interface AccountModel {
id: number;
account_type: AccountType;
@@ -145,6 +159,7 @@ export interface AccountModel {
imap_quota_bytes?: number;
auto_download_new_mailboxes?: boolean;
download_schedule?: string;
archive_rules?: ArchiveRules;
deleting?: boolean;
}
+67
View File
@@ -0,0 +1,67 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// 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 { ChevronRight, Home } from "lucide-react";
import { Link } from "@tanstack/react-router";
import { cn } from "@/lib/utils";
export interface BreadcrumbItem {
label: string;
to?: string;
}
interface BreadcrumbProps {
items: BreadcrumbItem[];
className?: string;
}
export function Breadcrumb({ items, className }: BreadcrumbProps) {
return (
<nav
aria-label="Breadcrumb"
className={cn("flex items-center gap-1.5 text-sm text-muted-foreground", className)}
>
<Link
to="/"
className="flex items-center gap-1 hover:text-foreground transition-colors"
>
<Home className="h-3.5 w-3.5" />
</Link>
{items.map((item, idx) => {
const isLast = idx === items.length - 1;
return (
<div key={idx} className="flex items-center gap-1.5">
<ChevronRight className="h-3.5 w-3.5" />
{item.to && !isLast ? (
<Link
to={item.to}
className="hover:text-foreground transition-colors"
>
{item.label}
</Link>
) : (
<span className={cn(isLast && "text-foreground font-medium")}>
{item.label}
</span>
)}
</div>
);
})}
</nav>
);
}
+260
View File
@@ -0,0 +1,260 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// 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 { useCallback, useState } from "react";
import { useForm, FormProvider } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslation } from "react-i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate, Link } from "@tanstack/react-router";
import { ArrowLeft, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Form } from "@/components/ui/form";
import { useToast } from "@/hooks/use-toast";
import { ToastAction } from "@/components/ui/toast";
import { Breadcrumb } from "@/components/ui/breadcrumb";
import { FixedHeader } from "@/components/layout/fixed-header";
import { Main } from "@/components/layout/main";
import { TabGeneral } from "./components/tab-general";
import { TabServer } from "./components/tab-server";
import { TabDownload } from "./components/tab-download";
import { TabFilters } from "./components/tab-filters";
import { create_account, autoconfig } from "@/api/account/api";
import { getAccountSchema, type AccountFormValues } from "./components/schema";
import type { AxiosError } from "axios";
const defaultValues: AccountFormValues = {
login_name: undefined,
account_name: undefined,
email: '',
imap: {
host: "",
port: 993,
encryption: 'Ssl',
auth: { auth_type: 'Password', password: undefined },
use_proxy: undefined,
},
enabled: true,
use_dangerous: false,
date_since: undefined,
date_before: undefined,
download_interval_min: 60,
download_batch_size: 30,
max_email_size_bytes: 100 * 1024 * 1024,
auto_download_new_mailboxes: true,
download_schedule: undefined,
archive_rules: undefined,
};
function SectionHeader({ title, description }: { title: string; description?: string }) {
return (
<div className="pb-3">
<h3 className="text-base font-semibold">{title}</h3>
{description && <p className="text-sm text-muted-foreground mt-0.5">{description}</p>}
</div>
);
}
export function AccountNewPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const { toast } = useToast();
const queryClient = useQueryClient();
const [autoConfigLoading, setAutoConfigLoading] = useState(false);
const accountSchema = getAccountSchema(false, t);
const form = useForm<AccountFormValues>({
mode: "onChange",
defaultValues,
resolver: zodResolver(accountSchema),
});
const createMutation = useMutation({
mutationFn: create_account,
onSuccess: () => {
toast({
title: t('accounts.accountCreated'),
description: t('accounts.accountCreatedDesc'),
action: <ToastAction altText={t('common.close')}>{t('common.close')}</ToastAction>,
});
queryClient.invalidateQueries({ queryKey: ['account-list'] });
navigate({ to: '/accounts' });
},
onError: (error: AxiosError) => {
const errorMessage =
(error.response?.data as { message?: string })?.message ||
error.message ||
t('accounts.creationFailed');
toast({
variant: "destructive",
title: t('accounts.accountCreationFailed'),
description: errorMessage as string,
action: <ToastAction altText={t('common.tryAgain')}>{t('common.tryAgain')}</ToastAction>,
});
},
});
const onSubmit = useCallback(
(data: AccountFormValues) => {
createMutation.mutate({
email: data.email,
account_name: data.account_name,
login_name: data.login_name,
imap: {
...data.imap,
auth: {
...data.imap.auth,
password: data.imap.auth.auth_type === 'OAuth2' ? undefined : data.imap.auth.password,
},
},
enabled: data.enabled,
use_dangerous: data.use_dangerous,
date_since: data.date_since,
date_before: data.date_before,
download_interval_min: data.download_interval_min,
download_batch_size: data.download_batch_size,
max_email_size_bytes: data.max_email_size_bytes,
auto_download_new_mailboxes: data.auto_download_new_mailboxes,
download_schedule: data.download_schedule || null,
account_type: "IMAP",
archive_rules: data.archive_rules || null,
});
},
[createMutation]
);
const handleAutoConfig = async () => {
const email = form.getValues('email');
if (!email) return;
const imap = form.getValues('imap');
if (imap.host.trim() !== "" && imap.port > 0) return;
setAutoConfigLoading(true);
try {
const result = await autoconfig(email);
if (result) {
form.setValue('imap.host', result.imap.host);
form.setValue('imap.port', result.imap.port);
form.setValue('imap.encryption', result.imap.encryption);
if (result.oauth2) form.setValue('imap.auth.auth_type', 'OAuth2');
}
} catch (error) {
console.error('Auto-configuration failed:', error);
}
setAutoConfigLoading(false);
};
return (
<>
<FixedHeader />
<Main>
<div className="mx-auto w-full max-w-[46rem] px-4 py-6">
<div className="mb-6 space-y-3">
<Link
to="/accounts"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4" />
{t('accounts.settings.backToAccounts')}
</Link>
<Breadcrumb items={[
{ label: t('accounts.title'), to: '/accounts' },
{ label: t('accounts.settings.newAccount') },
]} />
</div>
<div className="rounded-lg border shadow-sm bg-card p-6 md:p-8">
<div className="mb-6">
<h2 className="text-xl font-bold">{t('accounts.addAccount')}</h2>
<p className="text-sm text-muted-foreground mt-1">{t('accounts.addNewEmailAccountHere')}</p>
</div>
<FormProvider {...form}>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-10">
<section>
<SectionHeader
title={t('accounts.settings.general')}
description={t('accounts.settings.generalDesc')}
/>
<TabGeneral />
</section>
<hr />
<section>
<SectionHeader
title={t('accounts.settings.server')}
description={t('accounts.settings.serverDesc')}
/>
<div className="flex items-center gap-2 mb-4">
<Button
type="button"
variant="outline"
size="sm"
disabled={autoConfigLoading}
onClick={handleAutoConfig}
>
{autoConfigLoading && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
{autoConfigLoading ? t('accounts.autoConfiguring') : t('accounts.autoDiscover')}
</Button>
</div>
<TabServer />
</section>
<hr />
<section>
<SectionHeader
title={t('accounts.settings.download')}
description={t('accounts.settings.downloadDesc')}
/>
<TabDownload />
</section>
<hr />
<section>
<SectionHeader
title={t('accounts.settings.filters')}
description={t('accounts.settings.filtersDesc')}
/>
<TabFilters />
</section>
<div className="flex items-center justify-between pt-4 border-t">
<Button
type="button"
variant="outline"
onClick={() => navigate({ to: '/accounts' })}
>
{t('common.cancel')}
</Button>
<Button type="submit" size="lg" disabled={createMutation.isPending}>
{createMutation.isPending ? t('accounts.creating') : t('accounts.submit')}
</Button>
</div>
</form>
</Form>
</FormProvider>
</div>
</div>
</Main>
</>
);
}
@@ -0,0 +1,279 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// 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 { useCallback, useEffect } from "react";
import { useForm, FormProvider } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslation } from "react-i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Form } from "@/components/ui/form";
import { useToast } from "@/hooks/use-toast";
import { ToastAction } from "@/components/ui/toast";
import { Breadcrumb } from "@/components/ui/breadcrumb";
import { FixedHeader } from "@/components/layout/fixed-header";
import { Main } from "@/components/layout/main";
import { TabGeneral } from "./components/tab-general";
import { TabServer } from "./components/tab-server";
import { TabDownload } from "./components/tab-download";
import { TabFilters } from "./components/tab-filters";
import { update_account, list_accounts, type AccountModel } from "@/api/account/api";
import { getAccountSchema, type AccountFormValues } from "./components/schema";
import type { AxiosError } from "axios";
import { useQuery } from "@tanstack/react-query";
const emptyImap = {
host: "",
port: 0,
encryption: "None" as const,
auth: { auth_type: "Password" as const, password: undefined },
use_proxy: undefined,
};
function mapAccountToFormValues(account: AccountModel): AccountFormValues {
const imap = { ...(account.imap ?? emptyImap) };
imap.auth = { ...imap.auth, password: undefined };
if ((imap as any).use_proxy === null) {
(imap as any).use_proxy = undefined;
}
return {
account_name: account.account_name ?? undefined,
login_name: account.login_name ?? undefined,
email: account.email,
imap,
enabled: account.enabled,
use_dangerous: account.use_dangerous,
date_since: account.date_since ?? undefined,
date_before: account.date_before ?? undefined,
download_interval_min: account.download_interval_min ?? 60,
download_batch_size: account.download_batch_size ?? 30,
max_email_size_bytes: account.max_email_size_bytes ?? 100 * 1024 * 1024,
auto_download_new_mailboxes: account.auto_download_new_mailboxes ?? true,
download_schedule: account.download_schedule ?? undefined,
archive_rules: account.archive_rules ?? undefined,
};
}
function SectionHeader({ title, description }: { title: string; description?: string }) {
return (
<div className="pb-3">
<h3 className="text-base font-semibold">{title}</h3>
{description && <p className="text-sm text-muted-foreground mt-0.5">{description}</p>}
</div>
);
}
interface AccountSettingsPageProps {
accountId: number;
}
export function AccountSettingsPage({ accountId }: AccountSettingsPageProps) {
const { t } = useTranslation();
//const navigate = useNavigate();
const { toast } = useToast();
const queryClient = useQueryClient();
const { data: accountList } = useQuery({
queryKey: ['account-list'],
queryFn: list_accounts,
});
const account = accountList?.items?.find((a) => a.id === accountId);
const accountSchema = getAccountSchema(true, t);
const form = useForm<AccountFormValues>({
mode: "onChange",
defaultValues: account ? mapAccountToFormValues(account) : undefined,
resolver: zodResolver(accountSchema),
});
useEffect(() => {
if (account) {
form.reset(mapAccountToFormValues(account));
}
}, [account?.id]);
const updateMutation = useMutation({
mutationFn: (data: Record<string, any>) => update_account(accountId, data),
onSuccess: () => {
toast({
title: t('accounts.settings.saved'),
description: t('accounts.settings.savedDesc'),
action: <ToastAction altText={t('common.close')}>{t('common.close')}</ToastAction>,
});
queryClient.invalidateQueries({ queryKey: ['account-list'] });
},
onError: (error: AxiosError) => {
const errorMessage =
(error.response?.data as { message?: string })?.message ||
error.message ||
t('accounts.updateFailed');
toast({
variant: "destructive",
title: t('accounts.accountUpdateFailed'),
description: errorMessage as string,
action: <ToastAction altText={t('common.tryAgain')}>{t('common.tryAgain')}</ToastAction>,
});
},
});
const onSubmit = useCallback(
(data: AccountFormValues) => {
const payload: Record<string, any> = {
email: data.email,
account_name: data.account_name,
login_name: data.login_name,
imap: {
...data.imap,
auth: {
...data.imap.auth,
password: data.imap.auth.auth_type === 'OAuth2'
? undefined
: (data.imap.auth.password ? data.imap.auth.password : undefined),
},
},
enabled: data.enabled,
use_dangerous: data.use_dangerous,
date_since: data.date_since,
date_before: data.date_before,
download_interval_min: data.download_interval_min,
download_batch_size: data.download_batch_size,
max_email_size_bytes: data.max_email_size_bytes,
auto_download_new_mailboxes: data.auto_download_new_mailboxes,
download_schedule: data.download_schedule || null,
archive_rules: data.archive_rules || null,
};
if (!data.date_since && !data.date_before) {
payload.clear_date_range = true;
}
if (!data.download_schedule && account?.download_schedule) {
payload.clear_download_schedule = true;
}
updateMutation.mutate(payload);
},
[updateMutation, account]
);
if (!account) {
return (
<>
<FixedHeader />
<Main>
<div className="mx-auto w-full max-w-[46rem] px-4 py-12 text-center text-muted-foreground">
{t('accounts.settings.loading')}
</div>
</Main>
</>
);
}
return (
<>
<FixedHeader />
<Main>
<div className="mx-auto w-full max-w-[46rem] px-4 py-6">
<div className="mb-6 space-y-3">
<Link
to="/accounts"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4" />
{t('accounts.settings.backToAccounts')}
</Link>
<Breadcrumb items={[
{ label: t('accounts.title'), to: '/accounts' },
{ label: account.email },
{ label: t('accounts.settings.settings') },
]} />
</div>
<div className="rounded-lg border shadow-sm bg-card p-6 md:p-8">
<div className="mb-6">
<h2 className="text-xl font-bold">{account.email}</h2>
<p className="text-sm text-muted-foreground mt-1">{t('accounts.updateTheEmailAccountHere')}</p>
</div>
<FormProvider {...form}>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-10">
<section>
<SectionHeader
title={t('accounts.settings.general')}
description={t('accounts.settings.generalDesc')}
/>
<TabGeneral isEdit />
</section>
<hr />
<section>
<SectionHeader
title={t('accounts.settings.server')}
description={t('accounts.settings.serverDesc')}
/>
<TabServer isEdit />
</section>
<hr />
<section>
<SectionHeader
title={t('accounts.settings.download')}
description={t('accounts.settings.downloadDesc')}
/>
<TabDownload />
</section>
<hr />
<section>
<SectionHeader
title={t('accounts.settings.filters')}
description={t('accounts.settings.filtersDesc')}
/>
<TabFilters />
</section>
<div className="flex items-center justify-between pt-4 border-t">
<Button
type="button"
variant="outline"
onClick={() => {
if (account) form.reset(mapAccountToFormValues(account));
}}
>
{t('accounts.settings.reset')}
</Button>
<Button type="submit" size="lg" disabled={updateMutation.isPending}>
{updateMutation.isPending ? t('accounts.settings.saving') : t('accounts.saveChanges')}
</Button>
</div>
</form>
</Form>
</FormProvider>
</div>
</div>
</Main>
</>
);
}
@@ -1,368 +0,0 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// 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 { zodResolver } from '@hookform/resolvers/zod';
import * as React from 'react';
import { useForm } from 'react-hook-form';
import { Button } from '@/components/ui/button';
import { Form } from '@/components/ui/form';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useToast } from '@/hooks/use-toast';
import Step1 from './step1';
import Step2 from './step2';
import Step3 from './step3';
import Step4 from './step4';
import { create_account, autoconfig, update_account, AccountModel, ImapConfig } from '@/api/account/api';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ToastAction } from '@/components/ui/toast';
import { AxiosError } from 'axios';
import { useTranslation } from 'react-i18next';
import { cn } from "@/lib/utils";
import { getAccountSchema, type AccountFormValues } from './schema';
export type Account = AccountFormValues;
type Step = {
id: `step-${number}`;
name: string;
fields: (keyof Account)[];
};
export type Steps = [...Step[]];
const getSteps = (t: (key: string) => string): Steps => [
{ id: "step-1", name: t('accounts.steps.emailAddress'), fields: ["email", "account_name"] },
{ id: "step-2", name: t('accounts.steps.imap'), fields: ["imap", "use_dangerous", "login_name"] },
{ id: "step-3", name: t('accounts.steps.syncPreferences'), fields: ["enabled", "date_since", "date_before", "download_interval_min", "download_batch_size", "max_email_size_bytes", "auto_download_new_mailboxes", "download_schedule"] },
{ id: "step-4", name: t('accounts.steps.summary'), fields: [] },
];
const LAST_STEP = 4;
interface Props {
currentRow?: AccountModel;
open: boolean;
onOpenChange: (open: boolean) => void;
}
const defaultValues: Account = {
login_name: undefined,
account_name: undefined,
email: '',
imap: {
host: "",
port: 993,
encryption: 'Ssl',
auth: {
auth_type: 'Password',
password: undefined,
},
use_proxy: undefined
},
enabled: true,
use_dangerous: false,
date_since: undefined,
date_before: undefined,
download_interval_min: 60,
download_batch_size: 30,
max_email_size_bytes: 100 * 1024 * 1024,
auto_download_new_mailboxes: true,
download_schedule: undefined,
};
const emptyImap: ImapConfig = {
host: "",
port: 0,
encryption: "None",
auth: { auth_type: "Password", password: undefined },
use_proxy: undefined,
};
const mapCurrentRowToFormValues = (currentRow: AccountModel): Account => {
const imap = { ...(currentRow.imap ?? emptyImap) };
imap.auth = { ...imap.auth, password: undefined };
if (imap.use_proxy === null) {
imap.use_proxy = undefined;
}
return {
account_name: currentRow.account_name ?? undefined,
login_name: currentRow.login_name ?? undefined,
email: currentRow.email,
imap,
enabled: currentRow.enabled,
use_dangerous: currentRow.use_dangerous,
date_since: currentRow.date_since ?? undefined,
date_before: currentRow.date_before ?? undefined,
download_interval_min: currentRow.download_interval_min ?? 60,
download_batch_size: currentRow.download_batch_size ?? 30,
max_email_size_bytes: currentRow.max_email_size_bytes ?? 100 * 1024 * 1024,
auto_download_new_mailboxes: currentRow.auto_download_new_mailboxes ?? true,
download_schedule: currentRow.download_schedule ?? undefined,
};
};
export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
const { t } = useTranslation();
const steps = getSteps(t);
const isEdit = !!currentRow;
const [currentStep, setCurrentStep] = React.useState(1);
const { toast } = useToast();
const [autoConfigLoading, setAutoConfigLoading] = React.useState(false);
const accountSchema = getAccountSchema(isEdit, t);
const form = useForm<Account>({
mode: "onChange",
defaultValues: isEdit ? mapCurrentRowToFormValues(currentRow) : defaultValues,
resolver: zodResolver(accountSchema),
});
const queryClient = useQueryClient();
const createMutation = useMutation({
mutationFn: create_account,
onSuccess: handleSuccess,
onError: handleError,
});
const updateMutation = useMutation({
mutationFn: (data: Record<string, any>) => update_account(currentRow?.id!, data),
onSuccess: handleSuccess,
onError: handleError,
});
function handleSuccess() {
toast({
title: isEdit ? t('accounts.accountUpdated') : t('accounts.accountCreated'),
description: isEdit ? t('accounts.accountUpdatedDesc') : t('accounts.accountCreatedDesc'),
action: <ToastAction altText={t('common.close')}>{t('common.close')}</ToastAction>,
});
queryClient.invalidateQueries({ queryKey: ['account-list'] });
form.reset();
onOpenChange(false);
}
function handleError(error: AxiosError) {
const errorMessage =
(error.response?.data as { message?: string })?.message ||
error.message ||
(isEdit ? t('accounts.updateFailed') : t('accounts.creationFailed'));
toast({
variant: "destructive",
title: isEdit ? t('accounts.accountUpdateFailed') : t('accounts.accountCreationFailed'),
description: errorMessage as string,
action: <ToastAction altText={t('common.tryAgain')}>{t('common.tryAgain')}</ToastAction>,
});
console.error(error);
}
const onSubmit = React.useCallback(
(data: Account) => {
const commonData = {
email: data.email,
account_name: data.account_name,
login_name: data.login_name,
imap: {
...data.imap,
auth: {
...data.imap.auth,
password: data.imap.auth.auth_type === 'OAuth2'
? undefined
: (isEdit && !data.imap.auth.password ? undefined : data.imap.auth.password),
},
},
enabled: data.enabled,
use_dangerous: data.use_dangerous,
date_since: data.date_since,
date_before: data.date_before,
download_interval_min: data.download_interval_min,
download_batch_size: data.download_batch_size,
max_email_size_bytes: data.max_email_size_bytes,
auto_download_new_mailboxes: data.auto_download_new_mailboxes,
download_schedule: data.download_schedule || null,
};
if (isEdit) {
const isAllMode = !data.date_since && !data.date_before;
const clear_download_schedule = !data.download_schedule && currentRow?.download_schedule;
updateMutation.mutate({
...commonData,
...(isAllMode ? { clear_date_range: true } : {}),
...(clear_download_schedule ? { clear_download_schedule: true } : {})
});
} else {
createMutation.mutate({ ...commonData, account_type: "IMAP" });
}
},
[isEdit, updateMutation, createMutation]
);
const handleNav = async (index: number) => {
let isValid = true;
let failedStep = currentStep;
for (let i = currentStep - 1; i < index - 1 && isValid; i++) {
isValid = await form.trigger(steps[i].fields);
if (!isValid) failedStep = i;
}
if (isValid) setCurrentStep(index);
else setCurrentStep(failedStep);
};
async function handleContinue() {
const isValid = await form.trigger(steps[currentStep - 1].fields);
if (!isValid) return;
if (currentStep === 1) {
let allValues = form.getValues();
if (allValues.imap.host.trim() !== "" && allValues.imap.port > 0) {
handleNav(currentStep + 1);
return;
}
setAutoConfigLoading(true);
const email = form.getValues('email');
form.setValue('login_name', email);
try {
const result = await autoconfig(email);
if (result) {
form.setValue('imap.host', result.imap.host);
form.setValue('imap.port', result.imap.port);
form.setValue('imap.encryption', result.imap.encryption);
if (result.oauth2) form.setValue('imap.auth.auth_type', 'OAuth2');
}
} catch (error) {
console.error('Auto-configuration failed:', error);
}
setAutoConfigLoading(false);
handleNav(currentStep + 1);
} else {
handleNav(currentStep + 1);
}
}
return (
<Dialog
open={open}
onOpenChange={(state) => {
if (!state) {
form.reset();
setCurrentStep(1);
}
onOpenChange(state);
}}
>
<DialogContent className="max-w-[95vw] md:max-w-5xl w-full p-0 overflow-hidden flex flex-col h-[50rem]">
<div className="p-6 pb-2 flex-shrink-0">
<DialogHeader className="text-left">
<DialogTitle>{isEdit ? t('accounts.updateAccount') : t('accounts.addAccount')}</DialogTitle>
<DialogDescription>
{isEdit ? t('accounts.updateAccount') : t('accounts.addAccount')}
{t('accounts.clickSaveWhenDone')}
</DialogDescription>
</DialogHeader>
</div>
<div className="flex flex-col md:flex-row flex-1 min-h-0 overflow-hidden border-y">
<div className="md:hidden flex px-6 py-2 space-x-2 overflow-x-auto border-b flex-shrink-0 bg-background/50">
{steps.map((step, index) => (
<div key={step.id} className="flex flex-col items-center flex-shrink-0 min-w-[70px]">
<Button
variant={currentStep === index + 1 ? "default" : "secondary"}
className="size-8 rounded-full font-bold p-0"
disabled={currentStep === index + 1}
onClick={() => setCurrentStep(index + 1)}
>
{index + 1}
</Button>
<span className="text-[10px] mt-1 text-muted-foreground line-clamp-1">{step.name}</span>
</div>
))}
</div>
<div className="hidden md:block w-[240px] flex-shrink-0 px-8 py-4 border-r overflow-y-auto">
{steps.map((step, index) => (
<div className="mb-8 flex items-center" key={step.id}>
<Button
variant={currentStep === index + 1 ? "default" : "secondary"}
className="size-9 rounded-full text-sm font-bold"
disabled={currentStep === index + 1}
onClick={() => setCurrentStep(index + 1)}
>
{index + 1}
</Button>
<div className="flex flex-col items-baseline uppercase ml-4">
<span className="text-[10px] text-muted-foreground">{t('accounts.step', { index: index + 1 })}</span>
<span className={cn("font-bold text-sm tracking-wider", currentStep === index + 1 ? "text-foreground" : "text-muted-foreground")}>
{step.name}
</span>
</div>
</div>
))}
</div>
<div className="flex-1 min-h-0 relative">
<ScrollArea className="h-full w-full">
<div className="p-6 md:p-6 lg:p-8">
<Form {...form}>
<form id="account-register-form" onSubmit={form.handleSubmit(onSubmit)}>
{currentStep === 1 && <Step1 isEdit={isEdit} />}
{currentStep === 2 && <Step2 isEdit={isEdit} />}
{currentStep === 3 && <Step3 />}
{currentStep === 4 && <Step4 />}
</form>
</Form>
</div>
</ScrollArea>
</div>
</div>
<DialogFooter className="p-4 md:p-6 bg-background flex flex-row sm:justify-end gap-2 flex-shrink-0">
{currentStep > 1 && (
<Button
type="button"
variant="outline"
className="flex-1 sm:flex-none"
onClick={() => setCurrentStep(currentStep - 1)}
>
{t('accounts.goBack')}
</Button>
)}
{currentStep < LAST_STEP && (
<Button
type="button"
className="flex-1 sm:flex-none px-8"
onClick={handleContinue}
>
{autoConfigLoading ? t('accounts.autoConfiguring') : t('accounts.continue')}
</Button>
)}
{currentStep === LAST_STEP && (
<Button
type="submit"
form="account-register-form"
className="flex-1 sm:flex-none px-10"
>
{isEdit ? t('accounts.saveChanges') : t('accounts.submit')}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,159 +0,0 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// 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 * as React from 'react'
import { Mail, Database } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { useAccountContext } from '../context'
export type AddAccountType = 'IMAP' | 'NoSync'
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function AddAccountDialog({
open,
onOpenChange
}: Props) {
const { t } = useTranslation()
const { setOpen } = useAccountContext()
const [value, setValue] = React.useState<AddAccountType>('IMAP')
function handleContinue() {
if (value === 'IMAP') {
setOpen('add-imap')
} else {
setOpen('add-nosync')
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl">
<DialogHeader className="text-left">
<DialogTitle>
{t('accounts.add')}
</DialogTitle>
<DialogDescription>
{t('accounts.selectAccountType')}
</DialogDescription>
</DialogHeader>
<RadioGroup
value={value}
onValueChange={(v) => setValue(v as AddAccountType)}
className="space-y-4 py-2"
>
<Label
htmlFor="imap-account"
className={cn(
'flex cursor-pointer items-start gap-4 rounded-2xl border p-5 transition-all',
value === 'IMAP'
? 'border-primary bg-muted/50'
: 'hover:bg-muted/30'
)}
>
<RadioGroupItem
value="IMAP"
id="imap-account"
className="mt-1"
/>
<div className="flex flex-1 gap-4">
<div className="rounded-xl border p-2">
<Mail className="h-5 w-5" />
</div>
<div className="space-y-1">
<div className="font-medium">
{t('accounts.imapAccount')}
</div>
<div className="text-sm text-muted-foreground">
{t('accounts.imapAccountDescription')}
</div>
</div>
</div>
</Label>
<Label
htmlFor="nosync-account"
className={cn(
'flex cursor-pointer items-start gap-4 rounded-2xl border p-5 transition-all',
value === 'NoSync'
? 'border-primary bg-muted/50'
: 'hover:bg-muted/30'
)}
>
<RadioGroupItem
value="NoSync"
id="nosync-account"
className="mt-1"
/>
<div className="flex flex-1 gap-4">
<div className="rounded-xl border p-2">
<Database className="h-5 w-5" />
</div>
<div className="space-y-1">
<div className="font-medium">
{t('accounts.noSyncAccount')}
</div>
<div className="text-sm text-muted-foreground">
{t('accounts.noSyncAccountDescription')}
</div>
</div>
</div>
</Label>
</RadioGroup>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
>
{t('common.cancel')}
</Button>
<Button onClick={handleContinue}>
{t('accounts.continue')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -30,11 +30,12 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useAccountContext } from '../context'
import { Mailbox, MessageSquareMore } from 'lucide-react'
import { Mailbox, MessageSquareMore, Settings } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useCurrentUser } from '@/hooks/use-current-user'
import { AccountModel, cancel_account_download, start_account_download } from '@/api/account/api'
import { toast } from '@/hooks/use-toast'
import { useNavigate } from '@tanstack/react-router'
interface DataTableRowActionsProps {
row: Row<AccountModel>
@@ -43,6 +44,7 @@ interface DataTableRowActionsProps {
export function DataTableRowActions({ row }: DataTableRowActionsProps) {
const { t } = useTranslation()
const { setOpen, setCurrentRow } = useAccountContext()
const navigate = useNavigate()
const account_type = row.original.account_type;
const { require_any_permission } = useCurrentUser()
@@ -103,11 +105,10 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
<DropdownMenuContent align='end' className='w-[220px]'>
{hasPermission && <DropdownMenuItem
onClick={() => {
setCurrentRow(row.original)
if (account_type === "IMAP") {
setOpen("edit-imap");
}
if (account_type === "NoSync") {
navigate({ to: '/accounts/$id/settings', params: { id: String(row.original.id) } });
} else {
setCurrentRow(row.original)
setOpen("edit-nosync");
}
}}
@@ -117,6 +118,16 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
<IconEdit size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>}
{account_type === "IMAP" && hasPermission && <DropdownMenuItem
onClick={() => {
navigate({ to: '/accounts/$id/settings', params: { id: String(row.original.id) } });
}}
>
{t('accounts.settings.settings')}
<DropdownMenuShortcut>
<Settings size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>}
{account_type === "IMAP" && hasPermission && <DropdownMenuItem
onClick={() => {
setCurrentRow(row.original)
@@ -0,0 +1,90 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// 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 { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { X } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { MatchType, PatternEntry } from "@/lib/pattern-utils";
import { isValidRegex } from "@/lib/pattern-utils";
interface PatternInputProps {
entry: PatternEntry;
onChange: (id: string, entry: Partial<PatternEntry>) => void;
onRemove: (id: string) => void;
}
export function PatternInput({ entry, onChange, onRemove }: PatternInputProps) {
const { t } = useTranslation();
const matchTypeLabels: Record<MatchType, string> = {
contains: t("accounts.filters.matchType.contains"),
starts_with: t("accounts.filters.matchType.startsWith"),
ends_with: t("accounts.filters.matchType.endsWith"),
is_exactly: t("accounts.filters.matchType.isExactly"),
regex: t("accounts.filters.matchType.regex"),
};
const regexValid = entry.matchType === 'regex' ? isValidRegex(entry.value) : true;
const showRegexHint = entry.matchType === 'regex' && entry.value && !regexValid;
return (
<div className="flex items-center gap-2 group">
<Select
value={entry.matchType}
onValueChange={(v) => onChange(entry.id, { matchType: v as MatchType })}
>
<SelectTrigger className="w-[130px] h-9 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(matchTypeLabels).map(([value, label]) => (
<SelectItem key={value} value={value}>{label}</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex-1 relative">
<Input
className="h-9 text-sm"
value={entry.value}
onChange={(e) => onChange(entry.id, { value: e.target.value })}
/>
{showRegexHint && (
<span className="text-[10px] text-destructive absolute -bottom-4 left-0">
Invalid regex
</span>
)}
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 opacity-50 group-hover:opacity-100 transition-opacity"
onClick={() => onRemove(entry.id)}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
);
}
@@ -67,6 +67,19 @@ const dateSelectionSchema = (t: (key: string) => string) =>
})
.optional()
const filterRuleSchema = z.object({
include: z.array(z.string()),
exclude: z.array(z.string()),
})
const archiveRulesSchema = z.object({
enabled: z.boolean(),
senders: filterRuleSchema,
subjects: filterRuleSchema,
skip_larger_than: z.number().int().positive().optional(),
spam_headers: z.array(z.string()),
})
export const getAccountSchema = (isEdit: boolean, t: (key: string) => string) =>
z.object({
account_name: z.string().optional(),
@@ -120,6 +133,7 @@ export const getAccountSchema = (isEdit: boolean, t: (key: string) => string) =>
},
{ message: t('validation.invalidCronExpression') }
),
archive_rules: archiveRulesSchema.optional(),
})
export type AccountFormValues = z.infer<
@@ -1,88 +0,0 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// 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 { useFormContext } from "react-hook-form";
import {
FormField,
FormItem,
FormLabel,
FormMessage,
FormControl,
FormDescription,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Account } from "./action-dialog";
import { useTranslation } from "react-i18next";
interface StepProps {
isEdit: boolean;
}
export default function Step1({ isEdit }: StepProps) {
const { t } = useTranslation()
const { control } = useFormContext<Account>();
return (
<>
<h1 className="my-3 md:mt-8">{t('accounts.emailAccountRegistration')}</h1>
<p className="mb-5 md:mb-8">
{t('accounts.emailAccountRegistrationDesc')}
</p>
<div className="space-y-8">
<FormField
control={control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">
{t('accounts.emailAddress')}:
</FormLabel>
<FormControl>
<Input placeholder={t('accounts.emailPlaceholder')} disabled={isEdit} {...field} />
</FormControl>
<FormMessage />
{isEdit && (
<FormDescription>
{t('accounts.emailCannotBeModified')}
</FormDescription>
)}
</FormItem>
)}
/>
<FormField
control={control}
name="account_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.optional')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</>
);
}
@@ -1,236 +0,0 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// 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 {
FormField,
FormItem,
FormLabel,
FormMessage,
FormControl,
FormDescription,
} from "@/components/ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { useFormContext, useWatch } from "react-hook-form";
import { Account } from "./action-dialog";
import { PasswordInput } from "@/components/password-input";
import useProxyList from "@/hooks/use-proxy";
import { useTranslation } from "react-i18next";
import { Checkbox } from "@/components/ui/checkbox";
interface StepProps {
isEdit: boolean;
}
export default function Step2({ isEdit }: StepProps) {
const { t } = useTranslation()
const { control } = useFormContext<Account>();
const { proxyOptions } = useProxyList();
const imapAuthMethod = useWatch({
control,
name: "imap.auth.auth_type",
});
return (
<>
<div className="space-y-8">
<FormField
control={control}
name="imap.host"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">
{t('accounts.imapHost')}:
</FormLabel>
<FormControl>
<Input placeholder={t('accounts.imapHostPlaceholder')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="imap.port"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">
{t('accounts.imapPort')}:
</FormLabel>
<FormControl>
<Input type="number" placeholder={t('accounts.imapPortPlaceholder')} {...field} onChange={(e) => field.onChange(parseInt(e.target.value, 10))} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="imap.encryption"
render={({ field }) => (
<FormItem>
<FormLabel>{t('accounts.imapEncryption')}:</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('accounts.selectEncryptionMethod')} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="Ssl">Ssl</SelectItem>
<SelectItem value="StartTls">StartTls</SelectItem>
<SelectItem value="None">None</SelectItem>
</SelectContent>
</Select>
<FormDescription>
{t('accounts.chooseEncryptionMethod')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="use_dangerous"
render={({ field }) => (
<FormItem className="flex flex-col items-start gap-y-1">
<FormLabel>{t('accounts.useDangerous')}:</FormLabel>
<FormControl>
<Checkbox
className="mt-2"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>{t('accounts.useDangerousDescription')}</FormDescription>
</FormItem>
)}
/>
<FormField
control={control}
name="login_name"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">
{t('accounts.login_name')}:
</FormLabel>
<FormControl>
<Input placeholder={t('accounts.namePlaceholder')} {...field} disabled={isEdit} />
</FormControl>
<FormDescription>{t('accounts.nameDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="imap.auth.auth_type"
render={({ field }) => (
<FormItem>
<FormLabel>{t('accounts.imapAuthMethod')}:</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('accounts.selectAuthMethod')} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="OAuth2">OAuth2</SelectItem>
<SelectItem value="Password">Password</SelectItem>
</SelectContent>
</Select>
<FormDescription>
{t('accounts.chooseAuthMethod')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{imapAuthMethod === "Password" && (
<FormField
control={control}
name="imap.auth.password"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">
{t('accounts.imapPassword')}:
</FormLabel>
<FormControl>
<PasswordInput placeholder={isEdit ? t('accounts.leaveEmptyToKeepPassword') : t('accounts.enterPassword')} {...field} />
</FormControl>
<FormMessage />
{isEdit && (
<FormDescription>
{t('accounts.leaveEmptyToKeepExisting')}
</FormDescription>
)}
</FormItem>
)}
/>
)}
<FormField
control={control}
name='imap.use_proxy'
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">{t('accounts.useProxy')} ({t('accounts.optional')}):</FormLabel>
<FormControl>
<Select
onValueChange={(val) => {
field.onChange(val === 'none' ? undefined : Number(val))
}}
defaultValue={field.value?.toString()}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('accounts.selectProxy')} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem key="none" value="none">
{t('accounts.useNoProxy')}
</SelectItem>
{proxyOptions && proxyOptions.length > 0 && (
proxyOptions.map((option) => (
<SelectItem key={option.value} value={option.value.toString()}>
{option.label}
</SelectItem>
))
)}
</SelectContent>
</Select>
</FormControl>
<FormDescription className='flex-1'>
{t('accounts.imapProxy')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</>
);
}
@@ -1,567 +0,0 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// 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 {
FormField,
FormItem,
FormLabel,
FormMessage,
FormControl,
FormDescription,
} from "@/components/ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { useFormContext } from "react-hook-form";
import { Account } from "./action-dialog";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { format } from "date-fns";
import { CalendarIcon } from "lucide-react";
import { Calendar } from "@/components/ui/calendar";
import { cn, dateFnsLocaleMap } from "@/lib/utils";
import { useState } from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { useTranslation } from "react-i18next";
import { enUS } from "date-fns/locale";
import i18n from "@/i18n";
type SyncMode = 'all' | 'since_fixed' | 'since_relative' | 'before_relative';
type ScheduleMode = 'interval' | 'cron';
type CronMode = 'simple' | 'advanced';
type CronFrequency = 'daily' | 'weekly' | 'monthly';
interface CronSimpleState {
frequency: CronFrequency;
hour: number;
minute: number;
dayOfWeek: number;
dayOfMonth: number;
}
const DEFAULT_CRON_SIMPLE: CronSimpleState = {
frequency: 'daily',
hour: 0,
minute: 0,
dayOfWeek: 1,
dayOfMonth: 1,
};
function buildCronFromSimple(s: CronSimpleState): string {
switch (s.frequency) {
case 'daily':
return `0 ${s.minute} ${s.hour} * * *`;
case 'weekly':
return `0 ${s.minute} ${s.hour} * * ${s.dayOfWeek}`;
case 'monthly':
return `0 ${s.minute} ${s.hour} ${s.dayOfMonth} * *`;
}
}
function tryParseCronToSimple(cron: string): CronSimpleState | null {
const fields = cron.trim().split(/\s+/);
if (fields.length < 6) return null;
const sec = fields[0];
const min = fields[1];
const hour = fields[2];
const dom = fields[3];
const month = fields[4];
const dow = fields[5];
if (sec !== '0') return null;
if (month !== '*') return null;
const minuteVal = parseInt(min, 10);
const hourVal = parseInt(hour, 10);
if (isNaN(minuteVal) || isNaN(hourVal)) return null;
if (dom === '*' && dow === '*') {
return { frequency: 'daily', hour: hourVal, minute: minuteVal, dayOfWeek: 1, dayOfMonth: 1 };
}
if (dom === '*') {
const dowVal = parseInt(dow, 10);
if (!isNaN(dowVal)) {
return { frequency: 'weekly', hour: hourVal, minute: minuteVal, dayOfWeek: dowVal, dayOfMonth: 1 };
}
}
if (dow === '*') {
const domVal = parseInt(dom, 10);
if (!isNaN(domVal)) {
return { frequency: 'monthly', hour: hourVal, minute: minuteVal, dayOfWeek: 1, dayOfMonth: domVal };
}
}
return null;
}
export default function Step3() {
const { t } = useTranslation();
const { control, getValues, setValue } = useFormContext<Account>();
const current = getValues();
const [syncMode, setSyncMode] = useState<SyncMode>(() => {
if (current.date_before) return 'before_relative';
if (current.date_since?.fixed) return 'since_fixed';
if (current.date_since?.relative) return 'since_relative';
return 'all';
});
const [scheduleMode, setScheduleMode] = useState<ScheduleMode>(() => {
if (current.download_schedule) return 'cron';
return 'interval';
});
const [cronMode, setCronMode] = useState<CronMode>(() => {
if (current.download_schedule && tryParseCronToSimple(current.download_schedule)) {
return 'simple';
}
if (current.download_schedule) return 'advanced';
return 'simple';
});
const [cronSimple, setCronSimple] = useState<CronSimpleState>(() => {
if (current.download_schedule) {
return tryParseCronToSimple(current.download_schedule) ?? DEFAULT_CRON_SIMPLE;
}
return DEFAULT_CRON_SIMPLE;
});
const updateCronFromSimple = (partial: Partial<CronSimpleState>) => {
const next = { ...cronSimple, ...partial };
setCronSimple(next);
setValue('download_schedule', buildCronFromSimple(next));
};
const handleModeChange = (mode: SyncMode) => {
setSyncMode(mode);
setValue("date_since", undefined);
setValue("date_before", undefined);
if (mode === 'since_fixed') {
setValue("date_since.fixed", undefined);
} else if (mode === 'since_relative') {
setValue("date_since.relative", { value: 1, unit: 'Months' });
} else if (mode === 'before_relative') {
setValue("date_before", { value: 1, unit: 'Years' });
}
};
const handleScheduleModeChange = (mode: ScheduleMode) => {
setScheduleMode(mode);
if (mode === 'interval') {
setValue("download_schedule", undefined);
} else {
setValue("download_interval_min", 60);
if (cronMode === 'simple') {
updateCronFromSimple(cronSimple);
}
}
};
return (
<div className="space-y-8">
<div className="space-y-4">
<FormItem>
<FormLabel className="text-base font-semibold">{t('accounts.scheduleMode')}</FormLabel>
<FormDescription>
{t('accounts.scheduleModeDescription')}
</FormDescription>
<Select value={scheduleMode} onValueChange={(v) => handleScheduleModeChange(v as ScheduleMode)}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="interval">{t('accounts.scheduleModeInterval')}</SelectItem>
<SelectItem value="cron">{t('accounts.scheduleModeCron')}</SelectItem>
</SelectContent>
</Select>
</FormItem>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{scheduleMode === 'interval' ? (
<FormField
control={control}
name="download_interval_min"
render={({ field }) => (
<FormItem>
<FormLabel>{t('accounts.downloadInterval')}</FormLabel>
<FormControl>
<Input type="number" {...field} onChange={(e) => field.onChange(parseInt(e.target.value, 10))} />
</FormControl>
<FormMessage />
<FormDescription>
{t('accounts.downloadIntervalPlaceholder')}
</FormDescription>
</FormItem>
)}
/>
) : (
<div className="space-y-3">
<div className="flex items-center gap-2">
{/* <FormLabel className="text-sm font-medium">{t('accounts.downloadSchedule')}</FormLabel> */}
<div className="flex items-center rounded-md border text-xs">
<button
type="button"
className={`px-2 py-1 rounded-l-md ${cronMode === 'simple' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'}`}
onClick={() => setCronMode('simple')}
>
{t('accounts.cronSimple')}
</button>
<button
type="button"
className={`px-2 py-1 rounded-r-md ${cronMode === 'advanced' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'}`}
onClick={() => setCronMode('advanced')}
>
{t('accounts.cronAdvanced')}
</button>
</div>
</div>
{cronMode === 'simple' ? (
<div className="flex flex-wrap items-end gap-3">
<FormItem className="w-[140px]">
<FormLabel className="text-xs">{t('accounts.cronFrequency')}</FormLabel>
<Select
value={cronSimple.frequency}
onValueChange={(v) => updateCronFromSimple({ frequency: v as CronFrequency })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="daily">{t('accounts.cronDaily')}</SelectItem>
<SelectItem value="weekly">{t('accounts.cronWeekly')}</SelectItem>
<SelectItem value="monthly">{t('accounts.cronMonthly')}</SelectItem>
</SelectContent>
</Select>
</FormItem>
<FormItem className="w-[80px]">
<FormLabel className="text-xs">{t('accounts.cronHour')}</FormLabel>
<Select
value={String(cronSimple.hour)}
onValueChange={(v) => updateCronFromSimple({ hour: parseInt(v, 10) })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 24 }, (_, i) => (
<SelectItem key={i} value={String(i)}>
{String(i).padStart(2, '0')}
</SelectItem>
))}
</SelectContent>
</Select>
</FormItem>
<span className="text-muted-foreground pb-2">:</span>
<FormItem className="w-[80px]">
<FormLabel className="text-xs">{t('accounts.cronMinute')}</FormLabel>
<Select
value={String(cronSimple.minute)}
onValueChange={(v) => updateCronFromSimple({ minute: parseInt(v, 10) })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{[0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55].map((m) => (
<SelectItem key={m} value={String(m)}>
{String(m).padStart(2, '0')}
</SelectItem>
))}
</SelectContent>
</Select>
</FormItem>
{cronSimple.frequency === 'weekly' && (
<FormItem className="w-[140px]">
<FormLabel className="text-xs">{t('accounts.cronDayOfWeek')}</FormLabel>
<Select
value={String(cronSimple.dayOfWeek)}
onValueChange={(v) => updateCronFromSimple({ dayOfWeek: parseInt(v, 10) })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">{t('accounts.cronMonday')}</SelectItem>
<SelectItem value="2">{t('accounts.cronTuesday')}</SelectItem>
<SelectItem value="3">{t('accounts.cronWednesday')}</SelectItem>
<SelectItem value="4">{t('accounts.cronThursday')}</SelectItem>
<SelectItem value="5">{t('accounts.cronFriday')}</SelectItem>
<SelectItem value="6">{t('accounts.cronSaturday')}</SelectItem>
<SelectItem value="0">{t('accounts.cronSunday')}</SelectItem>
</SelectContent>
</Select>
</FormItem>
)}
{cronSimple.frequency === 'monthly' && (
<FormItem className="w-[90px]">
<FormLabel className="text-xs">{t('accounts.cronDayOfMonth')}</FormLabel>
<Select
value={String(cronSimple.dayOfMonth)}
onValueChange={(v) => updateCronFromSimple({ dayOfMonth: parseInt(v, 10) })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-[200px]">
{Array.from({ length: 28 }, (_, i) => i + 1).map((d) => (
<SelectItem key={d} value={String(d)}>
{d}
</SelectItem>
))}
</SelectContent>
</Select>
</FormItem>
)}
<div className="text-xs text-muted-foreground pb-2 font-mono">
= {buildCronFromSimple(cronSimple)}
</div>
</div>
) : (
<FormField
control={control}
name="download_schedule"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ''}
placeholder={t('accounts.downloadSchedulePlaceholder')}
/>
</FormControl>
<FormDescription>
{t('accounts.downloadScheduleDescription')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{cronMode === 'simple' && (
<FormDescription>{t('accounts.downloadScheduleDescription')}</FormDescription>
)}
<div className="text-xs text-muted-foreground mt-1 flex items-center gap-1">
<span>{t('accounts.cronTimezoneNote')}</span>
</div>
</div>
)}
<FormField
control={control}
name="download_batch_size"
render={({ field }) => (
<FormItem>
<FormLabel>{t('accounts.downloadBatchSize')}</FormLabel>
<FormControl>
<Input type="number" {...field} onChange={(e) => field.onChange(parseInt(e.target.value, 10))} />
</FormControl>
<FormMessage />
<FormDescription>
{t('accounts.downloadBatchSizeDescription')}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={control}
name="max_email_size_bytes"
render={({ field }) => {
const BYTES_PER_MB = 1024 * 1024;
return (
<FormItem>
<FormLabel>{t('accounts.maxEmailSizeBytes')}</FormLabel>
<FormControl>
<div className="flex items-center gap-2">
<Input
type="number"
placeholder={t('accounts.maxEmailSizeBytesPlaceholder')}
className="flex-1"
value={field.value ? field.value / BYTES_PER_MB : ''}
onChange={(e) => {
const parsed = parseInt(e.target.value, 10);
field.onChange(isNaN(parsed) ? parsed : parsed * BYTES_PER_MB);
}}
/>
<span className="text-sm text-muted-foreground whitespace-nowrap">MB</span>
</div>
</FormControl>
<FormMessage />
<FormDescription>
{t('accounts.maxEmailSizeBytesDescription')}
</FormDescription>
</FormItem>
);
}}
/>
</div>
</div>
<FormField
control={control}
name="enabled"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4 shadow-sm">
<FormControl>
<Checkbox checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>{t('accounts.enabled')}</FormLabel>
<FormDescription>{t('accounts.enabledDescription')}</FormDescription>
</div>
</FormItem>
)}
/>
<hr className="my-4" />
<div className="space-y-4">
<FormItem>
<FormLabel className="text-base font-semibold">{t('accounts.downloadScope')}</FormLabel>
<FormDescription>
{t('accounts.downloadScopeDescription')}
</FormDescription>
<Select value={syncMode} onValueChange={(v) => handleModeChange(v as SyncMode)}>
<SelectTrigger className="w-full">
<SelectValue placeholder={t('accounts.selectMode')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('accounts.downloadAll')}</SelectItem>
<SelectItem value="since_fixed">{t('accounts.sinceFixed')}</SelectItem>
<SelectItem value="since_relative">{t('accounts.sinceRelative')}</SelectItem>
<SelectItem value="before_relative">{t('accounts.beforeRelative')}</SelectItem>
</SelectContent>
</Select>
</FormItem>
<div className="pl-2 border-l-2 border-primary/20 space-y-4 pt-2">
{syncMode === 'since_fixed' && (
<FormField
control={control}
name="date_since.fixed"
render={({ field }) => {
const currentLang = i18n.language.toLowerCase().replace('_', '-');
const dateLocale = dateFnsLocaleMap[currentLang] || enUS;
return <FormItem className="flex flex-col">
<FormLabel>{t('accounts.selectDate')}</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn("w-[440px] pl-3 text-left font-normal", !field.value && "text-muted-foreground")}
>
{field.value ? format(new Date(field.value), "PPP", { locale: dateLocale }) : <span>{t('accounts.selectDate')}</span>}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value ? new Date(field.value) : undefined}
onSelect={(date) => field.onChange(date?.toLocaleDateString('en-CA'))}
disabled={(date) => date > new Date() || date < new Date("1900-01-01")}
locale={dateLocale}
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>;
}}
/>
)}
{(syncMode === 'since_relative' || syncMode === 'before_relative') && (
<div className="flex flex-row items-end gap-4 animate-in fade-in slide-in-from-left-2">
<FormField
control={control}
name={syncMode === 'since_relative' ? "date_since.relative.value" : "date_before.value"}
render={({ field }) => (
<FormItem className="flex-1 max-w-[150px]">
<FormLabel>{t('accounts.duration', 'Duration')}</FormLabel>
<FormControl>
<Input type="number" {...field} onChange={(e) => field.onChange(parseInt(e.target.value, 10))} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name={syncMode === 'since_relative' ? "date_since.relative.unit" : "date_before.unit"}
render={({ field }) => (
<FormItem className="w-[180px]">
<FormLabel>{t('accounts.unit', 'Unit')}</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('accounts.selectUnit')} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="Days">{t('accounts.days')}</SelectItem>
<SelectItem value="Months">{t('accounts.months')}</SelectItem>
<SelectItem value="Years">{t('accounts.years')}</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
</div>
<hr className="my-4" />
<FormField
control={control}
name="auto_download_new_mailboxes"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4 shadow-sm">
<FormControl>
<Checkbox checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>{t('accounts.autoDownloadNewMailboxes')}</FormLabel>
<FormDescription>{t('accounts.autoDownloadNewMailboxesDescription')}</FormDescription>
</div>
</FormItem>
)}
/>
<hr className="my-4" />
</div>
);
}
@@ -1,188 +0,0 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// 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 { useFormContext } from "react-hook-form";
import { Account } from "./action-dialog";
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "@/components/ui/accordion";
import { useTranslation } from "react-i18next";
import useProxyList from "@/hooks/use-proxy";
export default function Step4() {
const { t } = useTranslation();
const { getValues } = useFormContext<Account>();
const { getUrlById } = useProxyList();
const summaryData = getValues();
const sinceText = (() => {
if (summaryData.date_since?.fixed) {
return summaryData.date_since.fixed;
}
if (summaryData.date_since?.relative?.value) {
return `${t('accounts.sinceRelativeValue', {
value: summaryData.date_since!.relative!.value,
unit: t(`accounts.${summaryData.date_since!.relative!.unit!.toLowerCase()}`)
})}`;
}
return t('accounts.syncAll');
})();
const hasSince = !!summaryData.date_since;
const hasBefore = !!summaryData.date_before?.value;
return (
<div className="rounded-xl">
<Accordion type="multiple" defaultValue={[
'email', 'account_name', 'login_name', 'imap', 'date_since',
'max_email_size_bytes', 'sync_interval', 'sync_scope',
'sync_batch_size', 'download_schedule'
]}>
<AccordionItem key="email" value="email">
<AccordionTrigger className="font-medium capitalize text-gray-600">{t('accounts.email')}:</AccordionTrigger>
<AccordionContent>{summaryData.email}</AccordionContent>
</AccordionItem>
<AccordionItem key="account_name" value="account_name">
<AccordionTrigger className="font-medium capitalize text-gray-600">{t('accounts.name')}:</AccordionTrigger>
<AccordionContent>{summaryData.account_name ?? t('accounts.notAvailable')}</AccordionContent>
</AccordionItem>
<AccordionItem key="login_name" value="login_name">
<AccordionTrigger className="font-medium capitalize text-gray-600">{t('accounts.login_name')}:</AccordionTrigger>
<AccordionContent>{summaryData.login_name ?? t('accounts.notAvailable')}</AccordionContent>
</AccordionItem>
<AccordionItem key="imap" value="imap">
<AccordionTrigger className="font-medium capitalize text-gray-600">{t('accounts.imap')}:</AccordionTrigger>
<AccordionContent>
<div className="overflow-x-auto">
<table className="min-w-full divide-y">
<tbody className="divide-y">
<tr>
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">{t('accounts.host')}:</td>
<td className="px-6 py-2 whitespace-nowrap text-sm">{summaryData.imap.host}</td>
</tr>
<tr>
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">{t('accounts.port')}:</td>
<td className="px-6 py-2 whitespace-nowrap text-sm">{summaryData.imap.port}</td>
</tr>
<tr>
<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>
</tr>
{summaryData.imap.auth.auth_type === 'Password' && (
<tr>
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">{t('accounts.password')}:</td>
<td className="px-6 py-2 whitespace-nowrap text-sm break-words">{summaryData.imap.auth.password}</td>
</tr>
)}
<tr>
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">{t('accounts.useProxyField')}:</td>
<td className="px-6 py-2 whitespace-nowrap text-sm">
{(() => {
if (!summaryData.imap.use_proxy) {
return t('accounts.useNoProxy');
}
const proxyUrl = getUrlById(summaryData.imap.use_proxy);
return proxyUrl || `${t('common.yes')} (${summaryData.imap.use_proxy})`;
})()}
</td>
</tr>
</tbody>
</table>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem key="sync_scope" value="sync_scope">
<AccordionTrigger className="font-medium capitalize text-gray-600">
{t('accounts.downloadScope')}:
</AccordionTrigger>
<AccordionContent className="space-y-3">
{hasSince && (
<div className="flex flex-col">
<span className="text-xs text-muted-foreground">
{t('accounts.sinceFixed')}:
</span>
<span className="text-sm">{sinceText}</span>
</div>
)}
{hasBefore && (
<div className="flex flex-col border-t pt-2">
<span className="text-xs text-muted-foreground">
{t('accounts.beforeRelative')}:
</span>
<span className="text-sm">
{t('accounts.beforeRelativeValue', {
value: summaryData.date_before!.value,
unit: t(`accounts.${summaryData.date_before!.unit!.toLowerCase()}`)
})}
</span>
</div>
)}
{!hasSince && !hasBefore && (
<span className="text-sm">
{t('accounts.downloadAll')}
</span>
)}
</AccordionContent>
</AccordionItem>
<AccordionItem key="sync_interval" value="sync_interval">
<AccordionTrigger className="font-medium capitalize text-gray-600">{t('accounts.downloadInterval')}:</AccordionTrigger>
<AccordionContent>{summaryData.download_interval_min} {t('accounts.minutes')}</AccordionContent>
</AccordionItem>
<AccordionItem key="sync_batch_size" value="sync_batch_size">
<AccordionTrigger className="font-medium capitalize text-gray-600">{t('accounts.downloadBatchSize')}:</AccordionTrigger>
<AccordionContent>{summaryData.download_batch_size}</AccordionContent>
</AccordionItem>
<AccordionItem key="max_email_size_bytes" value="max_email_size_bytes">
<AccordionTrigger className="font-medium capitalize text-gray-600">{t('accounts.maxEmailSizeBytes')}:</AccordionTrigger>
<AccordionContent>{summaryData.max_email_size_bytes ? `${(summaryData.max_email_size_bytes / 1024 / 1024).toFixed(0)} MB` : t('accounts.maxEmailSizeBytesUnlimited')}</AccordionContent>
</AccordionItem>
<AccordionItem key="download_schedule" value="download_schedule">
<AccordionTrigger className="font-medium capitalize text-gray-600">{t('accounts.downloadSchedule')}:</AccordionTrigger>
<AccordionContent>{summaryData.download_schedule || t('accounts.notAvailable')}</AccordionContent>
</AccordionItem>
<AccordionItem key="auto_download_new_mailboxes" value="auto_download_new_mailboxes">
<AccordionTrigger className="font-medium capitalize text-gray-600">{t('accounts.autoDownloadNewMailboxes')}:</AccordionTrigger>
<AccordionContent>{summaryData.auto_download_new_mailboxes ? t('common.yes') : t('common.no')}</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
}
@@ -0,0 +1,564 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// 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 { useState } from "react";
import { useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { format } from "date-fns";
import { CalendarIcon } from "lucide-react";
import { enUS } from "date-fns/locale";
import {
FormField,
FormItem,
FormLabel,
FormMessage,
FormControl,
FormDescription,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Calendar } from "@/components/ui/calendar";
import { cn, dateFnsLocaleMap } from "@/lib/utils";
import i18n from "@/i18n";
import { AccountFormValues } from "./schema";
type SyncMode = 'all' | 'since_fixed' | 'since_relative' | 'before_relative';
type ScheduleMode = 'interval' | 'cron';
type CronMode = 'simple' | 'advanced';
type CronFrequency = 'daily' | 'weekly' | 'monthly';
interface CronSimpleState {
frequency: CronFrequency;
hour: number;
minute: number;
dayOfWeek: number;
dayOfMonth: number;
}
const DEFAULT_CRON_SIMPLE: CronSimpleState = {
frequency: 'daily',
hour: 0,
minute: 0,
dayOfWeek: 1,
dayOfMonth: 1,
};
function buildCronFromSimple(s: CronSimpleState): string {
switch (s.frequency) {
case 'daily':
return `0 ${s.minute} ${s.hour} * * *`;
case 'weekly':
return `0 ${s.minute} ${s.hour} * * ${s.dayOfWeek}`;
case 'monthly':
return `0 ${s.minute} ${s.hour} ${s.dayOfMonth} * *`;
}
}
function tryParseCronToSimple(cron: string): CronSimpleState | null {
const fields = cron.trim().split(/\s+/);
if (fields.length < 6) return null;
const sec = fields[0];
const min = fields[1];
const hour = fields[2];
const dom = fields[3];
const month = fields[4];
const dow = fields[5];
if (sec !== '0') return null;
if (month !== '*') return null;
const minuteVal = parseInt(min, 10);
const hourVal = parseInt(hour, 10);
if (isNaN(minuteVal) || isNaN(hourVal)) return null;
if (dom === '*' && dow === '*') {
return { frequency: 'daily', hour: hourVal, minute: minuteVal, dayOfWeek: 1, dayOfMonth: 1 };
}
if (dom === '*') {
const dowVal = parseInt(dow, 10);
if (!isNaN(dowVal)) {
return { frequency: 'weekly', hour: hourVal, minute: minuteVal, dayOfWeek: dowVal, dayOfMonth: 1 };
}
}
if (dow === '*') {
const domVal = parseInt(dom, 10);
if (!isNaN(domVal)) {
return { frequency: 'monthly', hour: hourVal, minute: minuteVal, dayOfWeek: 1, dayOfMonth: domVal };
}
}
return null;
}
export function TabDownload() {
const { t } = useTranslation();
const { control, getValues, setValue } = useFormContext<AccountFormValues>();
const current = getValues();
const [syncMode, setSyncMode] = useState<SyncMode>(() => {
if (current.date_before) return 'before_relative';
if (current.date_since?.fixed) return 'since_fixed';
if (current.date_since?.relative) return 'since_relative';
return 'all';
});
const [scheduleMode, setScheduleMode] = useState<ScheduleMode>(() => {
if (current.download_schedule) return 'cron';
return 'interval';
});
const [cronMode, setCronMode] = useState<CronMode>(() => {
if (current.download_schedule && tryParseCronToSimple(current.download_schedule)) {
return 'simple';
}
if (current.download_schedule) return 'advanced';
return 'simple';
});
const [cronSimple, setCronSimple] = useState<CronSimpleState>(() => {
if (current.download_schedule) {
return tryParseCronToSimple(current.download_schedule) ?? DEFAULT_CRON_SIMPLE;
}
return DEFAULT_CRON_SIMPLE;
});
const updateCronFromSimple = (partial: Partial<CronSimpleState>) => {
const next = { ...cronSimple, ...partial };
setCronSimple(next);
setValue('download_schedule', buildCronFromSimple(next));
};
const handleModeChange = (mode: SyncMode) => {
setSyncMode(mode);
setValue("date_since", undefined);
setValue("date_before", undefined);
if (mode === 'since_fixed') {
setValue("date_since.fixed", undefined);
} else if (mode === 'since_relative') {
setValue("date_since.relative", { value: 1, unit: 'Months' });
} else if (mode === 'before_relative') {
setValue("date_before", { value: 1, unit: 'Years' });
}
};
const handleScheduleModeChange = (mode: ScheduleMode) => {
setScheduleMode(mode);
if (mode === 'interval') {
setValue("download_schedule", undefined);
} else {
setValue("download_interval_min", 60);
if (cronMode === 'simple') {
updateCronFromSimple(cronSimple);
}
}
};
const BYTES_PER_MB = 1024 * 1024;
return (
<div className="space-y-8">
{/* Schedule */}
<div className="space-y-4">
<h4 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
{t('accounts.settings.schedule')}
</h4>
<FormItem>
<FormLabel>{t('accounts.scheduleMode')}</FormLabel>
<FormDescription>{t('accounts.scheduleModeDescription')}</FormDescription>
<Select value={scheduleMode} onValueChange={(v) => handleScheduleModeChange(v as ScheduleMode)}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="interval">{t('accounts.scheduleModeInterval')}</SelectItem>
<SelectItem value="cron">{t('accounts.scheduleModeCron')}</SelectItem>
</SelectContent>
</Select>
</FormItem>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{scheduleMode === 'interval' && (
<FormField
control={control}
name="download_interval_min"
render={({ field }) => (
<FormItem>
<FormLabel>{t('accounts.downloadInterval')}<span className="text-red-500 align-super text-xs">*</span></FormLabel>
<FormControl>
<Input type="number" {...field} onChange={(e) => field.onChange(parseInt(e.target.value, 10))} />
</FormControl>
<FormDescription>{t('accounts.downloadIntervalPlaceholder')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
{scheduleMode === 'cron' && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<div className="flex items-center rounded-md border text-xs">
<button
type="button"
className={`px-2 py-1 rounded-l-md ${cronMode === 'simple' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'}`}
onClick={() => setCronMode('simple')}
>
{t('accounts.cronSimple')}
</button>
<button
type="button"
className={`px-2 py-1 rounded-r-md ${cronMode === 'advanced' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'}`}
onClick={() => setCronMode('advanced')}
>
{t('accounts.cronAdvanced')}
</button>
</div>
</div>
{cronMode === 'simple' ? (
<>
<div className="flex items-end gap-2 overflow-x-auto pb-1">
<FormItem className="w-[120px] shrink-0">
<FormLabel className="text-sm">{t('accounts.cronFrequency')}</FormLabel>
<Select
value={cronSimple.frequency}
onValueChange={(v) => updateCronFromSimple({ frequency: v as CronFrequency })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="daily">{t('accounts.cronDaily')}</SelectItem>
<SelectItem value="weekly">{t('accounts.cronWeekly')}</SelectItem>
<SelectItem value="monthly">{t('accounts.cronMonthly')}</SelectItem>
</SelectContent>
</Select>
</FormItem>
<FormItem className="w-[72px] shrink-0">
<FormLabel className="text-sm">{t('accounts.cronHour')}</FormLabel>
<Select
value={String(cronSimple.hour)}
onValueChange={(v) => updateCronFromSimple({ hour: parseInt(v, 10) })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 24 }, (_, i) => (
<SelectItem key={i} value={String(i)}>
{String(i).padStart(2, '0')}
</SelectItem>
))}
</SelectContent>
</Select>
</FormItem>
<span className="text-muted-foreground font-medium pb-2 shrink-0">:</span>
<FormItem className="w-[72px] shrink-0">
<FormLabel className="text-sm">{t('accounts.cronMinute')}</FormLabel>
<Select
value={String(cronSimple.minute)}
onValueChange={(v) => updateCronFromSimple({ minute: parseInt(v, 10) })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{[0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55].map((m) => (
<SelectItem key={m} value={String(m)}>
{String(m).padStart(2, '0')}
</SelectItem>
))}
</SelectContent>
</Select>
</FormItem>
{cronSimple.frequency === 'weekly' && (
<FormItem className="w-[120px] shrink-0">
<FormLabel className="text-sm">{t('accounts.cronDayOfWeek')}</FormLabel>
<Select
value={String(cronSimple.dayOfWeek)}
onValueChange={(v) => updateCronFromSimple({ dayOfWeek: parseInt(v, 10) })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">{t('accounts.cronMonday')}</SelectItem>
<SelectItem value="2">{t('accounts.cronTuesday')}</SelectItem>
<SelectItem value="3">{t('accounts.cronWednesday')}</SelectItem>
<SelectItem value="4">{t('accounts.cronThursday')}</SelectItem>
<SelectItem value="5">{t('accounts.cronFriday')}</SelectItem>
<SelectItem value="6">{t('accounts.cronSaturday')}</SelectItem>
<SelectItem value="0">{t('accounts.cronSunday')}</SelectItem>
</SelectContent>
</Select>
</FormItem>
)}
{cronSimple.frequency === 'monthly' && (
<FormItem className="w-[80px] shrink-0">
<FormLabel className="text-sm">{t('accounts.cronDayOfMonth')}</FormLabel>
<Select
value={String(cronSimple.dayOfMonth)}
onValueChange={(v) => updateCronFromSimple({ dayOfMonth: parseInt(v, 10) })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-[200px]">
{Array.from({ length: 31 }, (_, i) => i + 1).map((d) => (
<SelectItem key={d} value={String(d)}>{d}</SelectItem>
))}
</SelectContent>
</Select>
</FormItem>
)}
</div>
<p className="text-xs text-muted-foreground">
{cronSimple.frequency === 'daily' && t('accounts.cronSummaryDaily', { hour: String(cronSimple.hour).padStart(2, '0'), minute: String(cronSimple.minute).padStart(2, '0') })}
{cronSimple.frequency === 'weekly' && (() => {
const dayNames = ['cronSunday', 'cronMonday', 'cronTuesday', 'cronWednesday', 'cronThursday', 'cronFriday', 'cronSaturday'];
return t('accounts.cronSummaryWeekly', { hour: String(cronSimple.hour).padStart(2, '0'), minute: String(cronSimple.minute).padStart(2, '0'), day: t(`accounts.${dayNames[cronSimple.dayOfWeek]}`) });
})()}
{cronSimple.frequency === 'monthly' && t('accounts.cronSummaryMonthly', { hour: String(cronSimple.hour).padStart(2, '0'), minute: String(cronSimple.minute).padStart(2, '0'), day: cronSimple.dayOfMonth })}
{cronSimple.frequency === 'monthly' && cronSimple.dayOfMonth > 28 && (
<span className="block text-xs text-yellow-600 mt-0.5">{t('accounts.cronMonthAlignNote')}</span>
)}
</p>
</>
) : (
<FormField
control={control}
name="download_schedule"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ''}
placeholder={t('accounts.downloadSchedulePlaceholder')}
/>
</FormControl>
<FormDescription>{t('accounts.downloadScheduleDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
<div className="text-xs text-muted-foreground mt-1 flex items-center gap-1">
<span>{t('accounts.cronTimezoneNote')}</span>
</div>
</div>
)}
</div>
<hr />
{/* Batch & Size */}
<div className="space-y-4">
<h4 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
{t('accounts.settings.performance')}
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={control}
name="download_batch_size"
render={({ field }) => (
<FormItem>
<FormLabel>{t('accounts.downloadBatchSize')}<span className="text-red-500 align-super text-xs">*</span></FormLabel>
<FormControl>
<Input type="number" {...field} onChange={(e) => field.onChange(parseInt(e.target.value, 10))} />
</FormControl>
<FormDescription>{t('accounts.downloadBatchSizeDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="max_email_size_bytes"
render={({ field }) => (
<FormItem>
<FormLabel>{t('accounts.maxEmailSizeBytes')}<span className="text-red-500 align-super text-xs">*</span></FormLabel>
<FormControl>
<div className="flex items-center gap-2">
<Input
type="number"
placeholder={t('accounts.maxEmailSizeBytesPlaceholder')}
className="flex-1"
value={field.value ? field.value / BYTES_PER_MB : ''}
onChange={(e) => {
const parsed = parseInt(e.target.value, 10);
field.onChange(isNaN(parsed) ? parsed : parsed * BYTES_PER_MB);
}}
/>
<span className="text-sm text-muted-foreground whitespace-nowrap">MB</span>
</div>
</FormControl>
<FormDescription>{t('accounts.maxEmailSizeBytesDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<hr />
{/* Download Scope */}
<div className="space-y-4">
<h4 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
{t('accounts.settings.scope')}
</h4>
<FormItem>
<FormLabel>{t('accounts.downloadScope')}</FormLabel>
<FormDescription>{t('accounts.downloadScopeDescription')}</FormDescription>
<Select value={syncMode} onValueChange={(v) => handleModeChange(v as SyncMode)}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('accounts.downloadAll')}</SelectItem>
<SelectItem value="since_fixed">{t('accounts.sinceFixed')}</SelectItem>
<SelectItem value="since_relative">{t('accounts.sinceRelative')}</SelectItem>
<SelectItem value="before_relative">{t('accounts.beforeRelative')}</SelectItem>
</SelectContent>
</Select>
<FormDescription className="mt-2">
{syncMode === 'all' && t('accounts.downloadAllDesc')}
{syncMode === 'since_fixed' && t('accounts.sinceFixedDesc')}
{syncMode === 'since_relative' && t('accounts.sinceRelativeDesc')}
{syncMode === 'before_relative' && t('accounts.beforeRelativeDesc')}
</FormDescription>
</FormItem>
<div className="pl-2 border-l-2 border-primary/20 space-y-4 pt-2">
{syncMode === 'since_fixed' && (
<FormField
control={control}
name="date_since.fixed"
render={({ field }) => {
const currentLang = i18n.language.toLowerCase().replace('_', '-');
const dateLocale = dateFnsLocaleMap[currentLang] || enUS;
return (
<FormItem className="flex flex-col">
<FormLabel>{t('accounts.selectDate')}<span className="text-red-500 align-super text-xs">*</span></FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn("w-[440px] pl-3 text-left font-normal", !field.value && "text-muted-foreground")}
>
{field.value ? format(new Date(field.value), "PPP", { locale: dateLocale }) : <span>{t('accounts.selectDate')}</span>}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value ? new Date(field.value) : undefined}
onSelect={(date) => field.onChange(date?.toLocaleDateString('en-CA'))}
disabled={(date) => date > new Date() || date < new Date("1900-01-01")}
locale={dateLocale}
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
);
}}
/>
)}
{(syncMode === 'since_relative' || syncMode === 'before_relative') && (
<div className="flex flex-row items-end gap-4">
<FormField
control={control}
name={syncMode === 'since_relative' ? "date_since.relative.value" : "date_before.value"}
render={({ field }) => (
<FormItem className="flex-1 max-w-[150px]">
<FormLabel>{t('accounts.duration')}<span className="text-red-500 align-super text-xs">*</span></FormLabel>
<FormControl>
<Input type="number" {...field} onChange={(e) => field.onChange(parseInt(e.target.value, 10))} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name={syncMode === 'since_relative' ? "date_since.relative.unit" : "date_before.unit"}
render={({ field }) => (
<FormItem className="w-[180px]">
<FormLabel>{t('accounts.unit', 'Unit')}<span className="text-red-500 align-super text-xs">*</span></FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="Days">{t('accounts.days')}</SelectItem>
<SelectItem value="Months">{t('accounts.months')}</SelectItem>
<SelectItem value="Years">{t('accounts.years')}</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
</div>
<hr />
<FormField
control={control}
name="auto_download_new_mailboxes"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
<FormControl>
<Checkbox checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>{t('accounts.autoDownloadNewMailboxes')}</FormLabel>
<FormDescription>{t('accounts.autoDownloadNewMailboxesDescription')}</FormDescription>
</div>
</FormItem>
)}
/>
</div>
);
}
@@ -0,0 +1,514 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// 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 { useFormContext, useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
import {
FormField,
FormItem,
FormLabel,
FormMessage,
FormControl,
FormDescription,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { Plus, HelpCircle } from "lucide-react";
import { PatternInput } from "./pattern-input";
import type { PatternEntry } from "@/lib/pattern-utils";
import { newPatternId, simplePatternToRegex } from "@/lib/pattern-utils";
import { useState } from "react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { AccountFormValues } from "./schema";
const SUGGESTED_SPAM_HEADERS = [
'X-Spam-Flag',
'X-Spam',
'X-Spam-Status',
'X-Barracuda-Spam-Status',
'X-Barracuda-Spam-Flag',
'X-MS-Exchange-Organization-SCL',
];
function toPatternEntries(patterns: string[]): PatternEntry[] {
return patterns.map((p) => ({
id: newPatternId(),
matchType: 'regex' as const,
value: p,
}));
}
function patternsToRegexList(entries: PatternEntry[]): string[] {
return entries
.filter((e) => e.value.trim() !== '')
.map((e) => simplePatternToRegex(e.matchType, e.value));
}
export function TabFilters() {
const { t } = useTranslation();
const { control, setValue } = useFormContext<AccountFormValues>();
const archiveRules = useWatch({ control, name: 'archive_rules' });
const enabled = archiveRules?.enabled ?? false;
const sendersInclude = archiveRules?.senders?.include ?? [];
const sendersExclude = archiveRules?.senders?.exclude ?? [];
const subjectsInclude = archiveRules?.subjects?.include ?? [];
const subjectsExclude = archiveRules?.subjects?.exclude ?? [];
const spamHeaders = archiveRules?.spam_headers ?? [];
const [senderIncludeEntries, setSenderIncludeEntries] = useState<PatternEntry[]>(
() => toPatternEntries(sendersInclude)
);
const [senderExcludeEntries, setSenderExcludeEntries] = useState<PatternEntry[]>(
() => toPatternEntries(sendersExclude)
);
const [subjectIncludeEntries, setSubjectIncludeEntries] = useState<PatternEntry[]>(
() => toPatternEntries(subjectsInclude)
);
const [subjectExcludeEntries, setSubjectExcludeEntries] = useState<PatternEntry[]>(
() => toPatternEntries(subjectsExclude)
);
// Resync local state when form values change externally (e.g. after form.reset)
const [lastSyncKey, setLastSyncKey] = useState<string>('');
const syncKey = JSON.stringify({ sendersInclude, sendersExclude, subjectsInclude, subjectsExclude });
if (syncKey !== lastSyncKey) {
setLastSyncKey(syncKey);
setSenderIncludeEntries(toPatternEntries(sendersInclude));
setSenderExcludeEntries(toPatternEntries(sendersExclude));
setSubjectIncludeEntries(toPatternEntries(subjectsInclude));
setSubjectExcludeEntries(toPatternEntries(subjectsExclude));
}
const syncToForm = (
includeEntries: PatternEntry[],
excludeEntries: PatternEntry[],
fieldPrefix: string
) => {
const include = patternsToRegexList(includeEntries);
const exclude = patternsToRegexList(excludeEntries);
setValue(`${fieldPrefix}.include` as any, include);
setValue(`${fieldPrefix}.exclude` as any, exclude);
};
const addEntry = (
side: 'include' | 'exclude',
includeEntries: PatternEntry[],
excludeEntries: PatternEntry[],
setIncludeEntries: React.Dispatch<React.SetStateAction<PatternEntry[]>>,
setExcludeEntries: React.Dispatch<React.SetStateAction<PatternEntry[]>>,
fieldPrefix: string
) => {
const newEntry: PatternEntry = { id: newPatternId(), matchType: 'contains', value: '' };
const newInclude = side === 'include' ? [...includeEntries, newEntry] : includeEntries;
const newExclude = side === 'exclude' ? [...excludeEntries, newEntry] : excludeEntries;
setIncludeEntries(newInclude);
setExcludeEntries(newExclude);
syncToForm(newInclude, newExclude, fieldPrefix);
};
const updateEntry = (
id: string,
partial: Partial<PatternEntry>,
side: 'include' | 'exclude',
includeEntries: PatternEntry[],
excludeEntries: PatternEntry[],
setIncludeEntries: React.Dispatch<React.SetStateAction<PatternEntry[]>>,
setExcludeEntries: React.Dispatch<React.SetStateAction<PatternEntry[]>>,
fieldPrefix: string
) => {
if (side === 'include') {
const updated = includeEntries.map((e) => (e.id === id ? { ...e, ...partial } : e));
setIncludeEntries(updated);
syncToForm(updated, excludeEntries, fieldPrefix);
} else {
const updated = excludeEntries.map((e) => (e.id === id ? { ...e, ...partial } : e));
setExcludeEntries(updated);
syncToForm(includeEntries, updated, fieldPrefix);
}
};
const removeEntry = (
id: string,
side: 'include' | 'exclude',
includeEntries: PatternEntry[],
excludeEntries: PatternEntry[],
setIncludeEntries: React.Dispatch<React.SetStateAction<PatternEntry[]>>,
setExcludeEntries: React.Dispatch<React.SetStateAction<PatternEntry[]>>,
fieldPrefix: string
) => {
if (side === 'include') {
const filtered = includeEntries.filter((e) => e.id !== id);
setIncludeEntries(filtered);
syncToForm(filtered, excludeEntries, fieldPrefix);
} else {
const filtered = excludeEntries.filter((e) => e.id !== id);
setExcludeEntries(filtered);
syncToForm(includeEntries, filtered, fieldPrefix);
}
};
const handleEnableChange = (checked: boolean) => {
if (checked) {
setValue('archive_rules', {
enabled: true,
senders: { include: [], exclude: [] },
subjects: { include: [], exclude: [] },
skip_larger_than: undefined,
spam_headers: [],
});
} else {
setValue('archive_rules', undefined);
}
};
const addSpamHeader = (header: string) => {
if (!spamHeaders.includes(header)) {
setValue('archive_rules.spam_headers', [...spamHeaders, header]);
}
};
const removeSpamHeader = (header: string) => {
setValue('archive_rules.spam_headers', spamHeaders.filter((h) => h !== header));
};
const [newSpamHeader, setNewSpamHeader] = useState('');
const handleAddCustomSpamHeader = () => {
const trimmed = newSpamHeader.trim();
if (trimmed && !spamHeaders.includes(trimmed)) {
setValue('archive_rules.spam_headers', [...spamHeaders, trimmed]);
setNewSpamHeader('');
}
};
const BYTES_PER_MB = 1024 * 1024;
return (
<div className="space-y-8">
{/* Master Switch */}
<div className="rounded-md border p-5 space-y-2 bg-muted/30">
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox checked={enabled} onCheckedChange={handleEnableChange} />
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>{t('accounts.filters.enableFiltering')}</FormLabel>
<FormDescription>
{t('accounts.filters.enableFilteringDesc')}
</FormDescription>
</div>
</FormItem>
</div>
{enabled && (
<>
{/* Sender Filters */}
<div className="space-y-4 rounded-md border p-5">
<div className="flex items-center gap-2">
<h4 className="text-sm font-semibold">{t('accounts.filters.senderFilter')}</h4>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="h-3.5 w-3.5 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
{t('accounts.filters.senderFilterHelp')}
</TooltipContent>
</Tooltip>
</div>
<div className="space-y-3">
<div>
<p className="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wider">
{t('accounts.filters.include')}
</p>
{senderIncludeEntries.length === 0 ? (
<p className="text-xs text-muted-foreground italic">
{t('accounts.filters.noIncludePatterns')}
</p>
) : (
<div className="space-y-2">
{senderIncludeEntries.map((entry) => (
<PatternInput
key={entry.id}
entry={entry}
onChange={(id, partial) =>
updateEntry(id, partial, 'include', senderIncludeEntries, senderExcludeEntries, setSenderIncludeEntries, setSenderExcludeEntries, 'archive_rules.senders')
}
onRemove={(id) =>
removeEntry(id, 'include', senderIncludeEntries, senderExcludeEntries, setSenderIncludeEntries, setSenderExcludeEntries, 'archive_rules.senders')
}
/>
))}
</div>
)}
<Button
variant="ghost"
type="button"
size="sm"
className="mt-2 h-8 text-xs"
onClick={() => addEntry('include', senderIncludeEntries, senderExcludeEntries, setSenderIncludeEntries, setSenderExcludeEntries, 'archive_rules.senders')}
>
<Plus className="h-3 w-3 mr-1" />
{t('accounts.filters.addPattern')}
</Button>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wider">
{t('accounts.filters.exclude')}
</p>
{senderExcludeEntries.length === 0 ? (
<p className="text-xs text-muted-foreground italic">
{t('accounts.filters.noExcludePatterns')}
</p>
) : (
<div className="space-y-2">
{senderExcludeEntries.map((entry) => (
<PatternInput
key={entry.id}
entry={entry}
onChange={(id, partial) =>
updateEntry(id, partial, 'exclude', senderIncludeEntries, senderExcludeEntries, setSenderIncludeEntries, setSenderExcludeEntries, 'archive_rules.senders')
}
onRemove={(id) =>
removeEntry(id, 'exclude', senderIncludeEntries, senderExcludeEntries, setSenderIncludeEntries, setSenderExcludeEntries, 'archive_rules.senders')
}
/>
))}
</div>
)}
<Button
variant="ghost"
type="button"
size="sm"
className="mt-2 h-8 text-xs"
onClick={() => addEntry('exclude', senderIncludeEntries, senderExcludeEntries, setSenderIncludeEntries, setSenderExcludeEntries, 'archive_rules.senders')}
>
<Plus className="h-3 w-3 mr-1" />
{t('accounts.filters.addPattern')}
</Button>
</div>
</div>
</div>
{/* Subject Filters */}
<div className="space-y-4 rounded-md border p-5">
<div className="flex items-center gap-2">
<h4 className="text-sm font-semibold">{t('accounts.filters.subjectFilter')}</h4>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="h-3.5 w-3.5 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
{t('accounts.filters.subjectFilterHelp')}
</TooltipContent>
</Tooltip>
</div>
<div className="space-y-3">
<div>
<p className="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wider">
{t('accounts.filters.include')}
</p>
{subjectIncludeEntries.length === 0 ? (
<p className="text-xs text-muted-foreground italic">
{t('accounts.filters.noIncludePatterns')}
</p>
) : (
<div className="space-y-2">
{subjectIncludeEntries.map((entry) => (
<PatternInput
key={entry.id}
entry={entry}
onChange={(id, partial) =>
updateEntry(id, partial, 'include', subjectIncludeEntries, subjectExcludeEntries, setSubjectIncludeEntries, setSubjectExcludeEntries, 'archive_rules.subjects')
}
onRemove={(id) =>
removeEntry(id, 'include', subjectIncludeEntries, subjectExcludeEntries, setSubjectIncludeEntries, setSubjectExcludeEntries, 'archive_rules.subjects')
}
/>
))}
</div>
)}
<Button
variant="ghost"
type="button"
size="sm"
className="mt-2 h-8 text-xs"
onClick={() => addEntry('include', subjectIncludeEntries, subjectExcludeEntries, setSubjectIncludeEntries, setSubjectExcludeEntries, 'archive_rules.subjects')}
>
<Plus className="h-3 w-3 mr-1" />
{t('accounts.filters.addPattern')}
</Button>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wider">
{t('accounts.filters.exclude')}
</p>
{subjectExcludeEntries.length === 0 ? (
<p className="text-xs text-muted-foreground italic">
{t('accounts.filters.noExcludePatterns')}
</p>
) : (
<div className="space-y-2">
{subjectExcludeEntries.map((entry) => (
<PatternInput
key={entry.id}
entry={entry}
onChange={(id, partial) =>
updateEntry(id, partial, 'exclude', subjectIncludeEntries, subjectExcludeEntries, setSubjectIncludeEntries, setSubjectExcludeEntries, 'archive_rules.subjects')
}
onRemove={(id) =>
removeEntry(id, 'exclude', subjectIncludeEntries, subjectExcludeEntries, setSubjectIncludeEntries, setSubjectExcludeEntries, 'archive_rules.subjects')
}
/>
))}
</div>
)}
<Button
variant="ghost"
type="button"
size="sm"
className="mt-2 h-8 text-xs"
onClick={() => addEntry('exclude', subjectIncludeEntries, subjectExcludeEntries, setSubjectIncludeEntries, setSubjectExcludeEntries, 'archive_rules.subjects')}
>
<Plus className="h-3 w-3 mr-1" />
{t('accounts.filters.addPattern')}
</Button>
</div>
</div>
</div>
{/* Size Limit */}
<div className="space-y-4 rounded-md border p-5">
<div className="flex items-center gap-2">
<h4 className="text-sm font-semibold">{t('accounts.filters.sizeLimit')}</h4>
</div>
<FormField
control={control}
name="archive_rules.skip_larger_than"
render={({ field }) => (
<FormItem>
<FormLabel>{t('accounts.filters.skipLargerThan')}</FormLabel>
<FormControl>
<div className="flex items-center gap-2">
<Input
type="number"
placeholder={t('accounts.filters.noLimit')}
className="max-w-[160px]"
value={field.value ? field.value / BYTES_PER_MB : ''}
onChange={(e) => {
const parsed = parseInt(e.target.value, 10);
field.onChange(isNaN(parsed) ? undefined : parsed * BYTES_PER_MB);
}}
/>
<span className="text-sm text-muted-foreground">MB</span>
</div>
</FormControl>
<FormDescription>{t('accounts.filters.sizeLimitDesc')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Spam Headers */}
<div className="space-y-4 rounded-md border p-5">
<div className="flex items-center gap-2">
<h4 className="text-sm font-semibold">{t('accounts.filters.spamHeaders')}</h4>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="h-3.5 w-3.5 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
{t('accounts.filters.spamHeadersHelp')}
</TooltipContent>
</Tooltip>
</div>
<div className="space-y-2">
{spamHeaders.length > 0 ? (
spamHeaders.map((header) => (
<div key={header} className="flex items-center gap-2">
<div className="flex-1 rounded-md border bg-muted/50 px-3 py-1.5 text-sm font-mono">
{header}
</div>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => removeSpamHeader(header)}
>
<span className="text-muted-foreground">&#x2715;</span>
</Button>
</div>
))
) : (
<p className="text-xs text-muted-foreground italic">
{t('accounts.filters.noSpamHeaders')}
</p>
)}
</div>
<div className="flex items-center gap-2">
<Input
className="h-8 text-sm max-w-[220px]"
placeholder="X-Spam-Flag"
value={newSpamHeader}
onChange={(e) => setNewSpamHeader(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddCustomSpamHeader();
}
}}
/>
<Button
variant="outline"
type="button"
size="sm"
className="h-8 text-xs"
onClick={handleAddCustomSpamHeader}
>
<Plus className="h-3 w-3 mr-1" />
{t('accounts.filters.addHeader')}
</Button>
</div>
<div>
<p className="text-xs text-muted-foreground mb-2">{t('accounts.filters.suggestions')}</p>
<div className="flex flex-wrap gap-1.5">
{SUGGESTED_SPAM_HEADERS.filter((h) => !spamHeaders.includes(h)).map((header) => (
<button
key={header}
type="button"
className="inline-flex items-center rounded-full border bg-background px-2.5 py-0.5 text-xs font-mono text-muted-foreground hover:text-foreground hover:border-primary/50 transition-colors"
onClick={() => addSpamHeader(header)}
>
+ {header}
</button>
))}
</div>
</div>
</div>
</>
)}
</div>
);
}
@@ -0,0 +1,91 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// 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 { useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import {
FormField,
FormItem,
FormLabel,
FormMessage,
FormControl,
FormDescription,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { AccountFormValues } from "./schema";
interface TabGeneralProps {
isEdit?: boolean;
}
export function TabGeneral({ isEdit }: TabGeneralProps) {
const { t } = useTranslation();
const { control } = useFormContext<AccountFormValues>();
return (
<div className="space-y-6">
<FormField
control={control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>{t('accounts.email')}<span className="text-red-500 align-super text-xs">*</span></FormLabel>
<FormControl>
<Input {...field} disabled={isEdit} placeholder={t('accounts.emailPlaceholder')} />
</FormControl>
{isEdit && (
<FormDescription>{t('accounts.emailCannotBeModified')}</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="account_name"
render={({ field }) => (
<FormItem>
<FormLabel>{t('accounts.name')}</FormLabel>
<FormControl>
<Input {...field} value={field.value ?? ''} placeholder={t('accounts.nameDescription')} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="enabled"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
<FormControl>
<Checkbox checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>{t('accounts.enabled')}</FormLabel>
<FormDescription>{t('accounts.enabledDescription')}</FormDescription>
</div>
</FormItem>
)}
/>
</div>
);
}
@@ -0,0 +1,180 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// 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 { useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import {
FormField,
FormItem,
FormLabel,
FormMessage,
FormControl,
FormDescription,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { PasswordInput } from "@/components/password-input";
import { AccountFormValues } from "./schema";
interface TabServerProps {
isEdit?: boolean;
}
export function TabServer({ isEdit }: TabServerProps) {
const { t } = useTranslation();
const { control, watch } = useFormContext<AccountFormValues>();
const authType = watch('imap.auth.auth_type');
return (
<div className="space-y-6">
<FormField
control={control}
name="imap.host"
render={({ field }) => (
<FormItem>
<FormLabel>{t('accounts.imapHost')}<span className="text-red-500 align-super text-xs">*</span></FormLabel>
<FormControl>
<Input {...field} placeholder={t('accounts.imapHostPlaceholder')} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={control}
name="imap.port"
render={({ field }) => (
<FormItem>
<FormLabel>{t('accounts.imapPort')}<span className="text-red-500 align-super text-xs">*</span></FormLabel>
<FormControl>
<Input
type="number"
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10))}
placeholder={t('accounts.imapPortPlaceholder')}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="imap.encryption"
render={({ field }) => (
<FormItem>
<FormLabel>{t('accounts.imapEncryption')}<span className="text-red-500 align-super text-xs">*</span></FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="Ssl">SSL/TLS</SelectItem>
<SelectItem value="StartTls">StartTLS</SelectItem>
<SelectItem value="None">{t('accounts.none')}</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={control}
name="login_name"
render={({ field }) => (
<FormItem>
<FormLabel>{t('accounts.login_name')}</FormLabel>
<FormControl>
<Input {...field} value={field.value ?? ''} placeholder={t('accounts.namePlaceholder')} disabled={isEdit} />
</FormControl>
<FormDescription>{t('accounts.nameDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="imap.auth.auth_type"
render={({ field }) => (
<FormItem>
<FormLabel>{t('accounts.imapAuthMethod')}<span className="text-red-500 align-super text-xs">*</span></FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="Password">{t('accounts.authPassword')}</SelectItem>
<SelectItem value="OAuth2">OAuth2</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{authType === 'Password' && (
<FormField
control={control}
name="imap.auth.password"
render={({ field }) => (
<FormItem>
<FormLabel>{t('accounts.imapPassword')}{!isEdit && <span className="text-red-500 align-super text-xs">*</span>}</FormLabel>
<FormControl>
<PasswordInput placeholder={isEdit ? t('accounts.leaveEmptyToKeepPassword') : t('accounts.enterPassword')} {...field} />
</FormControl>
{isEdit && <FormDescription>{t('accounts.leaveEmptyToKeepPassword')}</FormDescription>}
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={control}
name="use_dangerous"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
<FormControl>
<Checkbox checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>{t('accounts.useDangerous')}</FormLabel>
</div>
</FormItem>
)}
/>
</div>
);
}
@@ -21,10 +21,7 @@ import { AccountModel } from '@/api/account/api';
import React from 'react'
export type AccountDialogType =
| 'add'
| 'add-imap'
| 'add-nosync'
| 'edit-imap'
| 'edit-nosync'
| 'delete'
| 'detail'
+18 -35
View File
@@ -19,16 +19,16 @@
import { useState } from 'react'
import useDialogState from '@/hooks/use-dialog-state'
import { useNavigate } from '@tanstack/react-router'
import { Button } from '@/components/ui/button'
import { Main } from '@/components/layout/main'
import { AccountActionDialog } from './components/action-dialog'
import { useColumns } from './components/columns'
import { AccountDeleteDialog } from './components/delete-dialog'
import { AccountTable } from './components/table'
import AccountProvider, {
type AccountDialogType,
} from './context'
import { Plus } from 'lucide-react'
import { Mail, Database } from 'lucide-react'
import Logo from '@/assets/logo.svg'
import { AccountDetailDrawer } from './components/account-detail'
import { AccountModel, list_accounts } from '@/api/account/api'
@@ -42,10 +42,10 @@ import { NoSyncAccountDialog } from './components/nosync-dialog'
import { useTranslation } from 'react-i18next'
import { AccountAccessAssignmentDialog } from './components/access-assignment-dialog'
import { useCurrentUser } from '@/hooks/use-current-user'
import { AddAccountDialog } from './components/add-account-dialog'
export default function Accounts() {
const { t } = useTranslation()
const navigate = useNavigate()
const columns = useColumns()
// Dialog states
const [currentRow, setCurrentRow] = useState<AccountModel | null>(null)
@@ -77,15 +77,14 @@ export default function Accounts() {
</p>
</div>
{require_any_permission(['system:root', 'account:create']) && <div className="flex gap-2">
<div className="flex rounded-md shadow-sm">
<Button
onClick={() => setOpen("add")}
className="border-r-0"
>
<Plus className="h-4 w-4" />
{t('accounts.add')}
</Button>
</div>
<Button onClick={() => navigate({ to: '/accounts/new' })}>
<Mail className="mr-1.5 h-4 w-4" />
{t('accounts.imapAccount')}
</Button>
<Button variant="outline" onClick={() => setOpen("add-nosync")}>
<Database className="mr-1.5 h-4 w-4" />
{t('accounts.noSyncAccount')}
</Button>
</div>}
</div>
@@ -107,8 +106,13 @@ export default function Accounts() {
{t('accounts.noAccountConfigurationsDesc')}
</p>
<div className="mt-4 flex flex-col items-center gap-3 sm:flex-row sm:flex-wrap sm:justify-center sm:gap-4">
<Button variant="default" className="w-64" onClick={() => setOpen("add")}>
{t('accounts.add')}
<Button variant="default" className="w-64" onClick={() => navigate({ to: '/accounts/new' })}>
<Mail className="mr-1.5 h-4 w-4" />
{t('accounts.imapAccount')}
</Button>
<Button variant="outline" className="w-64" onClick={() => setOpen('add-nosync')}>
<Database className="mr-1.5 h-4 w-4" />
{t('accounts.noSyncAccount')}
</Button>
</div>
</div>
@@ -117,16 +121,6 @@ export default function Accounts() {
</div>
</div>
</Main>
<AddAccountDialog
key='account-add'
open={open === 'add'}
onOpenChange={() => setOpen('add')} />
<AccountActionDialog
key='imap-account-add'
open={open === 'add-imap'}
onOpenChange={() => setOpen('add-imap')}
/>
<NoSyncAccountDialog
key='nosync-account-add'
@@ -136,17 +130,6 @@ export default function Accounts() {
{currentRow && (
<>
<AccountActionDialog
key={`imap-account-edit-${currentRow.id}`}
open={open === 'edit-imap'}
onOpenChange={() => {
setOpen('edit-imap')
setTimeout(() => {
setCurrentRow(null)
}, 500)
}}
currentRow={currentRow}
/>
<NoSyncAccountDialog
key={`nosync-account-edit-${currentRow.id}`}
open={open === 'edit-nosync'}
+58
View File
@@ -0,0 +1,58 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// 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/>.
export type MatchType = 'contains' | 'starts_with' | 'ends_with' | 'is_exactly' | 'regex';
export interface PatternEntry {
id: string;
matchType: MatchType;
value: string;
}
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export function simplePatternToRegex(matchType: MatchType, value: string): string {
switch (matchType) {
case 'contains':
return `.*${escapeRegex(value)}.*`;
case 'starts_with':
return `^${escapeRegex(value)}.*`;
case 'ends_with':
return `.*${escapeRegex(value)}$`;
case 'is_exactly':
return `^${escapeRegex(value)}$`;
case 'regex':
return value;
}
}
export function isValidRegex(pattern: string): boolean {
try {
new RegExp(pattern);
return true;
} catch {
return false;
}
}
let _idCounter = 0;
export function newPatternId(): string {
return `pat_${++_idCounter}_${Date.now()}`;
}
+53 -1
View File
@@ -99,10 +99,12 @@
"areYouSureYouWantTo": "هل أنت متأكد من أنك تريد {{action}} هذا الحساب؟",
"auth": "المصادقة",
"authType": "نوع_المصادقة",
"autoConfiguring": "جارٍ التكوين التلقائي...",
"autoConfiguring": "جاري التكوين التلقائي",
"autoDiscover": "اكتشاف إعدادات الخادم تلقائيًا",
"autoDownloadNewMailboxes": "إضافة المجلدات الجديدة تلقائيًا",
"autoDownloadNewMailboxesDescription": "إضافة المجلدات الجديدة المكتشفة تلقائيًا إلى قائمة التنزيل.",
"beforeRelative": "تنزيل رسائل البريد الإلكتروني القديمة فقط",
"beforeRelativeDesc": "تنزيل الرسائل الأقدم من الفترة الزمنية المحددة فقط. يتحرك تاريخ القطع تلقائياً مع مرور الوقت — مفيد لأرشفة الرسائل القديمة تدriجياً.",
"beforeRelativeValue": "تنزيل رسائل البريد الإلكتروني قبل {{value}} {{unit}} مضت",
"cancelDownload": "إلغاء التنزيل",
"cancelFailed": "فشل إلغاء مهمة التنزيل",
@@ -124,9 +126,13 @@
"cronHour": "الساعة",
"cronMinute": "الدقيقة",
"cronMonday": "الإثنين",
"cronMonthAlignNote": "ملاحظة: إذا كان الشهر أقصر، فسيتم التنفيذ في اليوم الأخير من الشهر.",
"cronMonthly": "شهرياً",
"cronSaturday": "السبت",
"cronSimple": "تعبير بسيط",
"cronSummaryDaily": "يومياً عند الساعة {{hour}}:{{minute}}",
"cronSummaryMonthly": "في اليوم {{day}} من كل شهر عند الساعة {{hour}}:{{minute}}",
"cronSummaryWeekly": "كل يوم {{day}} عند الساعة {{hour}}:{{minute}}",
"cronSunday": "الأحد",
"cronThursday": "الخميس",
"cronTimezoneNote": "جميع الأوقات بالتوقيت المحلي للخادم.",
@@ -145,6 +151,7 @@
"disableAccount": "تعطيل الحساب",
"disabled": "معطل",
"downloadAll": "تنزيل جميع رسائل البريد الإلكتروني",
"downloadAllDesc": "سيتم تنزيل جميع رسائل البريد الإلكتروني في كل صندوق بريد محدد، من أول رسالة إلى أحدثها. الأفضل للأرشفة الكاملة.",
"downloadBatchSize": "حجم دفعة التنزيل",
"downloadBatchSizeDescription": "عدد الرسائل التي يتم جلبها لكل طلب IMAP",
"downloadCancelled": "تم إلغاء المهمة",
@@ -176,6 +183,35 @@
"enterValue": "الرجاء إدخال قيمة",
"everyMinutes": "كل {{minutes}} دقيقة",
"field": "الحقل",
"filters": {
"addHeader": "إضافة رأس",
"addPattern": "إضافة نمط",
"enableFiltering": "تفعيل تصفية المحتوى",
"enableFilteringDesc": "عند التفعيل، سيتم أرشفة الرسائل التي تطابق القواعد أدناه فقط. عند التعطيل، يتم أرشفة جميع الرسائل.",
"exclude": "استبعاد",
"include": "تضمين",
"matchType": {
"contains": "يحتوي على",
"endsWith": "ينتهي بـ",
"isExactly": "يطابق تماماً",
"regex": "تعبير نمطي (Regex)",
"startsWith": "يبدأ بـ"
},
"noExcludePatterns": "لا توجد أنماط استبعاد — لن يتم استبعاد أي شيء.",
"noIncludePatterns": "لا توجد أنماط تضمين — سيتم تضمين كل شيء.",
"noLimit": "بلا حد",
"noSpamHeaders": "لم يتم تكوين رؤوس البريد المزعج.",
"senderFilter": "فلتر المرسل",
"senderFilterHelp": "تصفية الرسائل بناءً على عنوان بريد المرسل. تحدد أنماط التضمين المرسِلين المقبولين، بينما ترفض أنماط الاستبعاد من يطابقها.",
"sizeLimit": "حد الحجم",
"sizeLimitDesc": "سيتم تخطي الرسائل التي تتجاوز هذا الحجم أثناء الأرشفة. اتركه فارغاً لعدم وضع حد.",
"skipLargerThan": "تخطى الرسائل الأكبر من",
"spamHeaders": "رؤوس البريد المزعج",
"spamHeadersHelp": "سيتم تخطي الرسائل التي تحتوي على أي من هذه الرؤوس بقيمة 'yes' أو 'true'. تظهر أدناه بعض الرؤوس الشائعة لكشف البريد المزعج.",
"subjectFilter": "فلتر الموضوع",
"subjectFilterHelp": "تصفية الرسائل بناءً على سطر الموضوع. يعمل بنفس طريقة تصفية المرسل.",
"suggestions": "اقتراحات (اضغط للإضافة):"
},
"fixed": "ثابت",
"folderSync": {
"autoSelectDescendants": "تحديد العناصر الفرعية تلقائيًا",
@@ -284,9 +320,25 @@
"selectUnit": "اختر وحدة",
"selectedMailboxes": "صناديق البريد المحددة",
"serverConfiguration": "تكوين الخادم (IMAP)",
"settings": {
"download": "تنزيل",
"downloadDesc": "تكوين وقت وكيفية جلب رسائل البريد الإلكتروني من الخادم.",
"filters": "الفلاتر",
"filtersDesc": "التحكم في الرسائل التي يتم أرشفتها. عند تعطيل الفلترة، يتم حفظ جميع الرسائل.",
"general": "عام",
"generalDesc": "معلومات الحساب الأساسية والحالة.",
"newAccount": "حساب جديد",
"performance": "الأداء",
"schedule": "جدول المزامنة",
"scope": "نطاق المزامنة",
"server": "الخادم",
"serverDesc": "إعدادات اتصال IMAP والمصادقة."
},
"since": "منذ",
"sinceFixed": "منذ تاريخ محدد",
"sinceFixedDesc": "تنزيل الرسائل المستلمة بعد التاريخ المحدد فقط، وتجاهل ما قبله.",
"sinceRelative": "تنزيل رسائل البريد الإلكتروني الأخيرة فقط",
"sinceRelativeDesc": "تنزيل رسائل الفترة الأخيرة فقط (مثل آخر 3 أشهر). يتحرك تاريخ البدء تلقائياً مع مرور الوقت.",
"sinceRelativeValue": "تنزيل رسائل البريد الإلكتروني من آخر",
"startDownload": "بدء التنزيل",
"state": "الحالة",
+53 -1
View File
@@ -99,10 +99,12 @@
"areYouSureYouWantTo": "Er du sikker på, at du vil {{action}} denne konto?",
"auth": "Godkendelse",
"authType": "godkendelsestype",
"autoConfiguring": "Konfigurerer automatisk...",
"autoConfiguring": "Konfigurerer automatisk",
"autoDiscover": "Find serverindstillinger automatisk",
"autoDownloadNewMailboxes": "Tilføj automatisk nye mapper",
"autoDownloadNewMailboxesDescription": "Føj automatisk nye mapper til downloadlisten.",
"beforeRelative": "Download kun gamle e-mails",
"beforeRelativeDesc": "Download kun e-mails, der er ældre end den angivne periode. Skæringsdatoen flyttes automatisk nyttigt til gradvis arkivering af gamle e-mails.",
"beforeRelativeValue": "Download e-mails før {{value}} {{unit}} siden",
"cancelDownload": "Annuller download",
"cancelFailed": "Kunne ikke annullere download-opgave",
@@ -124,9 +126,13 @@
"cronHour": "Time",
"cronMinute": "Minut",
"cronMonday": "Mandag",
"cronMonthAlignNote": "Bemærk: Hvis måneden er kortere, udføres den på månedens sidste dag.",
"cronMonthly": "Månedligt",
"cronSaturday": "Lørdag",
"cronSimple": "Simpelt udtryk",
"cronSummaryDaily": "Hver dag kl. {{hour}}:{{minute}}",
"cronSummaryMonthly": "Den {{day}}. i hver måned kl. {{hour}}:{{minute}}",
"cronSummaryWeekly": "Hver {{day}} kl. {{hour}}:{{minute}}",
"cronSunday": "Søndag",
"cronThursday": "Torsdag",
"cronTimezoneNote": "Alle tider er serverens lokale tid.",
@@ -145,6 +151,7 @@
"disableAccount": "Deaktivér konto",
"disabled": "Deaktiveret",
"downloadAll": "Download alle e-mails",
"downloadAllDesc": "Alle e-mails i alle valgte postkasser vil blive downloadet. Bedst til komplet arkivering.",
"downloadBatchSize": "Download batchstørrelse",
"downloadBatchSizeDescription": "Antal beskeder hentet pr. IMAP-anmodning",
"downloadCancelled": "Opgave annulleret",
@@ -176,6 +183,35 @@
"enterValue": "Indtast en værdi",
"everyMinutes": "hvert {{minutes}} minut",
"field": "Felt",
"filters": {
"addHeader": "Tilføj header",
"addPattern": "Tilføj mønster",
"enableFiltering": "Aktiver indholdsfiltrering",
"enableFilteringDesc": "Når den er aktiveret, vil kun e-mails, der matcher reglerne nedenfor, blive arkiveret. Når den er deaktiveret, arkiveres alle e-mails.",
"exclude": "Ekskluder",
"include": "Inkluder",
"matchType": {
"contains": "Indeholder",
"endsWith": "Ender med",
"isExactly": "Er præcis",
"regex": "Regulært udtryk (Regex)",
"startsWith": "Begynder med"
},
"noExcludePatterns": "Ingen ekskluderingsmønstre intet er ekskluderet.",
"noIncludePatterns": "Ingen inkluderingsmønstre alt er inkluderet.",
"noLimit": "Ingen begrænsning",
"noSpamHeaders": "Ingen spamheadere konfigureret.",
"senderFilter": "Afsenderfilter",
"senderFilterHelp": "Filtrer e-mails baseret på afsenderens adresse. Inkluderingsmønstre tillader afsendere, mens ekskluderingsmønstre afviser dem.",
"sizeLimit": "Størrelsesbegrænsning",
"sizeLimitDesc": "E-mails, der er større end denne størrelse, vil blive sprunget over under arkivering. Lad feltet være tomt for ingen begrænsning.",
"skipLargerThan": "Spring e-mails over, der er større end",
"spamHeaders": "Spamheadere",
"spamHeadersHelp": "E-mails med en af disse headere sat til 'yes' eller 'true' vil blive sprunget over. Almindelige spamheadere er foreslået nedenfor.",
"subjectFilter": "Emnefilter",
"subjectFilterHelp": "Filtrer e-mails baseret på emnelinjen. Fungerer på samme måde som afsenderfiltrering.",
"suggestions": "Forslag (klik for at tilføje):"
},
"fixed": "Fast",
"folderSync": {
"autoSelectDescendants": "Vælg efterkommere automatisk",
@@ -284,9 +320,25 @@
"selectUnit": "Vælg en enhed",
"selectedMailboxes": "Valgte postkasser",
"serverConfiguration": "Serverkonfiguration (IMAP)",
"settings": {
"download": "Download",
"downloadDesc": "Konfigurer, hvornår og hvordan e-mails hentes fra serveren.",
"filters": "Filtre",
"filtersDesc": "Styr, hvilke e-mails der arkiveres. Når filtrering er deaktiveret, gemmes alle e-mails.",
"general": "Generelt",
"generalDesc": "Generelle kontooplysninger og status.",
"newAccount": "Ny konto",
"performance": "Ydeevne",
"schedule": "Tidsplan",
"scope": "Omfang",
"server": "Server",
"serverDesc": "IMAP-forbindelsesindstillinger og godkendelse."
},
"since": "siden",
"sinceFixed": "Siden specifik dato",
"sinceFixedDesc": "Download kun e-mails modtaget efter den valgte dato. E-mails før denne dato ignoreres.",
"sinceRelative": "Download kun seneste e-mails",
"sinceRelativeDesc": "Download kun e-mails fra den seneste periode (f.eks. de seneste 3 måneder). Startdatoen flyttes automatisk fremad.",
"sinceRelativeValue": "Download e-mails fra de sidste",
"startDownload": "Start download",
"state": "Tilstand",
+53 -1
View File
@@ -99,10 +99,12 @@
"areYouSureYouWantTo": "Möchten Sie dieses Konto wirklich {{action}}?",
"auth": "Authentifizierung",
"authType": "Authentifizierungstyp",
"autoConfiguring": "Automatische Konfiguration läuft...",
"autoConfiguring": "Automatische Konfiguration",
"autoDiscover": "Servereinstellungen automatisch erkennen",
"autoDownloadNewMailboxes": "Neue Ordner automatisch hinzufügen",
"autoDownloadNewMailboxesDescription": "Neu entdeckte Ordner automatisch zur Download-Liste hinzufügen.",
"beforeRelative": "Nur alte E-Mails herunterladen",
"beforeRelativeDesc": "Nur E-Mails herunterladen, die älter als der angegebene Zeitraum sind. Das Stichtagsdatum verschiebt sich automatisch ideal für die schrittweise Archivierung.",
"beforeRelativeValue": "E-Mails von vor {{value}} {{unit}} herunterladen",
"cancelDownload": "Download abbrechen",
"cancelFailed": "Download-Aufgabe konnte nicht abgebrochen werden",
@@ -124,9 +126,13 @@
"cronHour": "Stunde",
"cronMinute": "Minute",
"cronMonday": "Montag",
"cronMonthAlignNote": "Hinweis: Bei kürzeren Monaten erfolgt die Ausführung am letzten Tag des Monats.",
"cronMonthly": "Monatlich",
"cronSaturday": "Samstag",
"cronSimple": "Einfacher Ausdruck",
"cronSummaryDaily": "Täglich um {{hour}}:{{minute}} Uhr",
"cronSummaryMonthly": "Jeden {{day}}. des Monats um {{hour}}:{{minute}} Uhr",
"cronSummaryWeekly": "Wöchentlich, jeden {{day}} um {{hour}}:{{minute}} Uhr",
"cronSunday": "Sonntag",
"cronThursday": "Donnerstag",
"cronTimezoneNote": "Alle Zeiten nutzen Server-Ortszeit.",
@@ -145,6 +151,7 @@
"disableAccount": "Konto deaktivieren",
"disabled": "Deaktiviert",
"downloadAll": "Alle E-Mails herunterladen",
"downloadAllDesc": "Alle E-Mails in jedem ausgewählten Postfach werden heruntergeladen. Bestens geeignet für eine vollständige Archivierung.",
"downloadBatchSize": "Download-Batch-Größe",
"downloadBatchSizeDescription": "Anzahl der pro IMAP-Anfrage abgerufenen Nachrichten",
"downloadCancelled": "Aufgabe abgebrochen",
@@ -176,6 +183,35 @@
"enterValue": "Wert eingeben",
"everyMinutes": "alle {{minutes}} Minuten",
"field": "Feld",
"filters": {
"addHeader": "Header hinzufügen",
"addPattern": "Muster hinzufügen",
"enableFiltering": "Inhaltsfilterung aktivieren",
"enableFilteringDesc": "Wenn aktiviert, werden nur E-Mails archiviert, die den folgenden Regeln entsprechen. Wenn deaktiviert, werden alle E-Mails archiviert.",
"exclude": "Ausschließen",
"include": "Einschließen",
"matchType": {
"contains": "Enthält",
"endsWith": "Endet mit",
"isExactly": "Ist genau",
"regex": "Regulärer Ausdruck (Regex)",
"startsWith": "Beginnt mit"
},
"noExcludePatterns": "Keine Ausschlussmuster nichts wird ausgeschlossen.",
"noIncludePatterns": "Keine Einschlussmuster alles wird eingeschlossen.",
"noLimit": "Kein Limit",
"noSpamHeaders": "Keine Spam-Header konfiguriert.",
"senderFilter": "Absenderfilter",
"senderFilterHelp": "Filtert E-Mails nach Absenderadresse. Einschlussmuster lassen Absender zu, Ausschlussmuster weisen übereinstimmende Absender ab.",
"sizeLimit": "Größenbeschränkung",
"sizeLimitDesc": "E-Mails, die diese Größe überschreiten, werden bei der Archivierung übersprungen. Leer lassen für unbegrenzt.",
"skipLargerThan": "E-Mails überspringen größer als",
"spamHeaders": "Spam-Header",
"spamHeadersHelp": "E-Mails, bei denen einer dieser Header auf 'yes' oder 'true' gesetzt ist, werden übersprungen. Häufige Spam-Header sind unten angegeben.",
"subjectFilter": "Betreffsfilter",
"subjectFilterHelp": "Filtert E-Mails nach Betreffzeile. Funktioniert genauso wie die Absenderfilterung.",
"suggestions": "Vorschläge (zum Hinzufügen anklicken):"
},
"fixed": "Fest",
"folderSync": {
"autoSelectDescendants": "Unterelemente automatisch auswählen",
@@ -284,9 +320,25 @@
"selectUnit": "Einheit auswählen",
"selectedMailboxes": "Ausgewählte Postfächer",
"serverConfiguration": "Serverkonfiguration (IMAP)",
"settings": {
"download": "Herunterladen",
"downloadDesc": "Konfigurieren, wann und wie E-Mails vom Server abgerufen werden.",
"filters": "Filter",
"filtersDesc": "Steuern Sie, welche E-Mails archiviert werden. Wenn die Filterung deaktiviert ist, werden alle E-Mails gespeichert.",
"general": "Allgemein",
"generalDesc": "Basis-Kontoinformationen und Status.",
"newAccount": "Neues Konto",
"performance": "Leistung",
"schedule": "Zeitplan",
"scope": "Zeitraum",
"server": "Server",
"serverDesc": "IMAP-Verbindungseinstellungen und Authentifizierung."
},
"since": "seit",
"sinceFixed": "Seit einem bestimmten Datum",
"sinceFixedDesc": "Nur E-Mails herunterladen, die nach dem gewählten Datum empfangen wurden. Ältere E-Mails werden ignoriert.",
"sinceRelative": "Nur aktuelle E-Mails herunterladen",
"sinceRelativeDesc": "Nur E-Mails aus dem jüngsten Zeitraum herunterladen (z. B. letzte 3 Monate). Das Startdatum verschiebt sich automatisch.",
"sinceRelativeValue": "E-Mails der letzten Zeit herunterladen",
"startDownload": "Download starten",
"state": "Zustand",
+63 -1
View File
@@ -98,11 +98,14 @@
"allMailSkipped": "Selected standard folders. 'All Mail' was skipped to avoid duplicates.",
"areYouSureYouWantTo": "Are you sure you want to {{action}} this account?",
"auth": "Auth",
"authPassword": "Password",
"authType": "auth_type",
"autoConfiguring": "Auto-configuring...",
"autoDiscover": "Auto-discover Server Settings",
"autoDownloadNewMailboxes": "Auto-add new mailboxes",
"autoDownloadNewMailboxesDescription": "Automatically add newly discovered folders to the download list.",
"beforeRelative": "Download Old Emails Only",
"beforeRelativeDesc": "Only download emails older than the specified time period. The cutoff date automatically moves over time — useful for gradually archiving old emails while skipping recent ones.",
"beforeRelativeValue": "Download emails before {{value}} {{unit}} ago",
"cancelDownload": "Cancel download",
"cancelFailed": "Failed to cancel download task",
@@ -114,19 +117,24 @@
"clickSaveWhenDone": "Click save when you're done.",
"continue": "Continue",
"createdAt": "Created At",
"creating": "Creating...",
"creationFailed": "Creation failed, please try again later",
"cronAdvanced": "Advanced Expression",
"cronDaily": "Daily",
"cronDayOfMonth": "Day of Week",
"cronDayOfMonth": "Day of Month",
"cronDayOfWeek": "Day of Week",
"cronFrequency": "Frequency",
"cronFriday": "Friday",
"cronHour": "Hour",
"cronMinute": "Minute",
"cronMonday": "Monday",
"cronMonthAlignNote": "Note: If the month is shorter, it will execute on the last day of the month.",
"cronMonthly": "Monthly",
"cronSaturday": "Saturday",
"cronSimple": "Simple Expression",
"cronSummaryDaily": "Every day at {{hour}}:{{minute}}",
"cronSummaryMonthly": "On day {{day}} of every month at {{hour}}:{{minute}}",
"cronSummaryWeekly": "Every {{day}} at {{hour}}:{{minute}}",
"cronSunday": "Sunday",
"cronThursday": "Thursday",
"cronTimezoneNote": "All times use server local timezone.",
@@ -145,6 +153,7 @@
"disableAccount": "Disable account",
"disabled": "Disabled",
"downloadAll": "Download All Emails",
"downloadAllDesc": "All emails in every selected mailbox will be downloaded, from the very first to the latest. Best for complete archiving.",
"downloadBatchSize": "Download batch size",
"downloadBatchSizeDescription": "Number of messages fetched per IMAP request",
"downloadCancelled": "Task cancelled",
@@ -176,6 +185,35 @@
"enterValue": "Please enter a value",
"everyMinutes": "every {{minutes}} minutes",
"field": "Field",
"filters": {
"addHeader": "Add Header",
"addPattern": "Add pattern",
"enableFiltering": "Enable content filtering",
"enableFilteringDesc": "When enabled, only emails matching the rules below will be archived. When disabled, all emails are archived.",
"exclude": "Exclude",
"include": "Include",
"matchType": {
"contains": "Contains",
"endsWith": "Ends with",
"isExactly": "Is exactly",
"regex": "Regular expression (Regex)",
"startsWith": "Starts with"
},
"noExcludePatterns": "No exclude patterns — nothing is excluded.",
"noIncludePatterns": "No include patterns — everything is included.",
"noLimit": "No limit",
"noSpamHeaders": "No spam headers configured.",
"senderFilter": "Sender Filter",
"senderFilterHelp": "Filter emails based on the sender's email address. Include patterns determine which senders pass; exclude patterns reject matching senders.",
"sizeLimit": "Size Limit",
"sizeLimitDesc": "Emails larger than this size will be skipped during archiving. Leave empty for no size limit.",
"skipLargerThan": "Skip emails larger than",
"spamHeaders": "Spam Headers",
"spamHeadersHelp": "Emails with any of these headers set to 'yes' or 'true' will be skipped. Common spam detection headers are suggested below.",
"subjectFilter": "Subject Filter",
"subjectFilterHelp": "Filter emails based on the subject line. Works the same way as sender filtering.",
"suggestions": "Suggestions (click to add):"
},
"fixed": "Fixed",
"folderSync": {
"autoSelectDescendants": "Auto select descendants",
@@ -284,9 +322,33 @@
"selectUnit": "Select a unit",
"selectedMailboxes": "Selected Mailboxes",
"serverConfiguration": "Server Configuration (IMAP)",
"settings": {
"backToAccounts": "Back to Accounts",
"download": "Download",
"downloadDesc": "Configure when and how emails are fetched from the server.",
"filters": "Filters",
"filtersDesc": "Control which emails are archived. When filtering is disabled, all emails are saved.",
"general": "General",
"generalDesc": "Basic account information and status.",
"loading": "Loading account...",
"newAccount": "New Account",
"performance": "Performance",
"reset": "Reset",
"save": "Save",
"saved": "Saved",
"savedDesc": "Settings have been saved successfully.",
"saving": "Saving...",
"schedule": "Schedule",
"scope": "Scope",
"server": "Server",
"serverDesc": "IMAP connection settings and authentication.",
"settings": "Settings"
},
"since": "since",
"sinceFixed": "Since Specific Date",
"sinceFixedDesc": "Only download emails received after the selected date. Emails before that date will be ignored.",
"sinceRelative": "Download Recent Emails Only",
"sinceRelativeDesc": "Only download emails from the recent period (e.g. last 3 months). The start date automatically moves forward over time.",
"sinceRelativeValue": "Download emails from the last",
"startDownload": "Start download",
"state": "State",
+53 -1
View File
@@ -99,10 +99,12 @@
"areYouSureYouWantTo": "¿Está seguro de que desea {{action}} esta cuenta?",
"auth": "Autenticación",
"authType": "tipo de autenticación",
"autoConfiguring": "Autoconfigurando...",
"autoConfiguring": "Configurando automáticamente…",
"autoDiscover": "Detectar automáticamente la configuración del servidor",
"autoDownloadNewMailboxes": "Añadir automáticamente nuevas carpetas",
"autoDownloadNewMailboxesDescription": "Añadir automáticamente las nuevas carpetas a la lista de descarga.",
"beforeRelative": "Descargar solo correos antiguos",
"beforeRelativeDesc": "Solo descargar correos anteriores al período especificado. La fecha límite avanza automáticamente, ideal para archivar correos viejos gradualmente.",
"beforeRelativeValue": "Descargar correos de hace {{value}} {{unit}}",
"cancelDownload": "Cancelar descarga",
"cancelFailed": "Error al cancelar la tarea de descarga",
@@ -124,9 +126,13 @@
"cronHour": "Hora",
"cronMinute": "Minuto",
"cronMonday": "Lunes",
"cronMonthAlignNote": "Nota: Si el mes es más corto, se ejecutará el último día del mes.",
"cronMonthly": "Mensual",
"cronSaturday": "Sábado",
"cronSimple": "Expresión simple",
"cronSummaryDaily": "Todos los días a las {{hour}}:{{minute}}",
"cronSummaryMonthly": "El día {{day}} de cada mes a las {{hour}}:{{minute}}",
"cronSummaryWeekly": "Todos los {{day}} a las {{hour}}:{{minute}}",
"cronSunday": "Domingo",
"cronThursday": "Jueves",
"cronTimezoneNote": "Horas en zona horaria del servidor.",
@@ -145,6 +151,7 @@
"disableAccount": "Desactivar cuenta",
"disabled": "Deshabilitado",
"downloadAll": "Descargar todos los correos",
"downloadAllDesc": "Se descargarán todos los correos de cada buzón seleccionado. Ideal para un archivado completo.",
"downloadBatchSize": "Tamaño del lote de descarga",
"downloadBatchSizeDescription": "Número de mensajes obtenidos por solicitud IMAP",
"downloadCancelled": "Tarea cancelada",
@@ -176,6 +183,35 @@
"enterValue": "Introduce valor",
"everyMinutes": "cada {{minutes}} minutos",
"field": "Campo",
"filters": {
"addHeader": "Añadir encabezado",
"addPattern": "Añadir patrón",
"enableFiltering": "Activar filtrado de contenido",
"enableFilteringDesc": "Si está activado, solo se archivarán los correos que cumplan las siguientes reglas. Si está desactivado, se archivarán todos.",
"exclude": "Excluir",
"include": "Incluir",
"matchType": {
"contains": "Contiene",
"endsWith": "Termina en",
"isExactly": "Es exactamente",
"regex": "Expresión regular (Regex)",
"startsWith": "Empieza por"
},
"noExcludePatterns": "Sin patrones de exclusión: no se excluye nada.",
"noIncludePatterns": "Sin patrones de inclusión: se incluye todo.",
"noLimit": "Sin límite",
"noSpamHeaders": "No hay encabezados de spam configurados.",
"senderFilter": "Filtro de remitente",
"senderFilterHelp": "Filtra correos por dirección del remitente. Los patrones de inclusión permiten el paso; los de exclusión rechazan coincidencias.",
"sizeLimit": "Límite de tamaño",
"sizeLimitDesc": "Los correos que superen este tamaño se omitirán al archivar. Déjelo vacío si no desea un límite.",
"skipLargerThan": "Omitir correos mayores de",
"spamHeaders": "Encabezados de spam",
"spamHeadersHelp": "Se omitirán los correos con cualquiera de estos encabezados establecidos en 'yes' o 'true'. Abajo se sugieren encabezados comunes de detección de spam.",
"subjectFilter": "Filtro de asunto",
"subjectFilterHelp": "Filtra correos por la línea de asunto. Funciona de la misma manera que el filtrado de remitentes.",
"suggestions": "Sugerencias (haz clic para añadir):"
},
"fixed": "Fija",
"folderSync": {
"autoSelectDescendants": "Seleccionar automáticamente los descendientes",
@@ -284,9 +320,25 @@
"selectUnit": "Seleccionar unidad",
"selectedMailboxes": "Buzones seleccionados",
"serverConfiguration": "Configuración del servidor (IMAP)",
"settings": {
"download": "Descarga",
"downloadDesc": "Configure cuándo y cómo se obtienen los correos del servidor.",
"filters": "Filtros",
"filtersDesc": "Controle qué correos se archivan. Si el filtrado está desactivado, se guardarán todos.",
"general": "General",
"generalDesc": "Información básica de la cuenta y estado.",
"newAccount": "Nueva cuenta",
"performance": "Rendimiento",
"schedule": "Planificación",
"scope": "Alcance",
"server": "Servidor",
"serverDesc": "Configuración de conexión IMAP y autenticación."
},
"since": "desde",
"sinceFixed": "Desde una fecha específica",
"sinceFixedDesc": "Solo descargar correos posteriores a la fecha seleccionada. Los anteriores serán ignorados.",
"sinceRelative": "Descargar solo correos recientes",
"sinceRelativeDesc": "Solo descargar correos del período reciente (ej. últimos 3 meses). La fecha de inicio avanza automáticamente.",
"sinceRelativeValue": "Descargar correos de los últimos",
"startDownload": "Iniciar descarga",
"state": "Estado",
+53 -1
View File
@@ -99,10 +99,12 @@
"areYouSureYouWantTo": "Haluatko varmasti {{action}} tämän tilin?",
"auth": "Todennus",
"authType": "todennustyyppi",
"autoConfiguring": "Automaattinen määritys...",
"autoConfiguring": "Määritetään automaattisesti…",
"autoDiscover": "Hae palvelinasetukset automaattisesti",
"autoDownloadNewMailboxes": "Lisää uudet kansiot automaattisesti",
"autoDownloadNewMailboxesDescription": "Lisää uudet löydetyt kansiot automaattisesti latausluetteloon.",
"beforeRelative": "Lataa vain vanhat sähköpostit",
"beforeRelativeDesc": "Lataa vain määritettyä ajanjaksoa vanhemmat sähköpostit. Takaraja siirtyy automaattisesti hyödyllinen vanhojen sähköpostien asteittaiseen arkistointiin.",
"beforeRelativeValue": "Lataa sähköpostit {{value}} {{unit}} sitten",
"cancelDownload": "Peruuta lataus",
"cancelFailed": "Lataustehtävän peruuttaminen epäonnistui",
@@ -124,9 +126,13 @@
"cronHour": "Tunti",
"cronMinute": "Minuutti",
"cronMonday": "Maanantai",
"cronMonthAlignNote": "Huomautus: Jos kuukausi on lyhyempi, suoritus tapahtuu kuukauden viimeisenä päivänä.",
"cronMonthly": "Kuukausittain",
"cronSaturday": "Lauantai",
"cronSimple": "Yksinkertainen lauseke",
"cronSummaryDaily": "Joka päivä klo {{hour}}:{{minute}}",
"cronSummaryMonthly": "Joka kuukauden {{day}}. päivä klo {{hour}}:{{minute}}",
"cronSummaryWeekly": "Joka {{day}} klo {{hour}}:{{minute}}",
"cronSunday": "Sunnuntai",
"cronThursday": "Torstai",
"cronTimezoneNote": "Kaikki ajat palvelimen paikallista aikaa.",
@@ -145,6 +151,7 @@
"disableAccount": "Poista tili käytöstä",
"disabled": "Poistettu käytöstä",
"downloadAll": "Lataa kaikki sähköpostit",
"downloadAllDesc": "Kaikki sähköpostit valituista postilaatikoista ladataan. Paras täydelliseen arkistointiin.",
"downloadBatchSize": "Latauserän koko",
"downloadBatchSizeDescription": "IMAP-pyyntöä kohden noudettujen viestien määrä",
"downloadCancelled": "Tehtävä peruutettu",
@@ -176,6 +183,35 @@
"enterValue": "Syötä arvo",
"everyMinutes": "joka {{minutes}} minuutti",
"field": "Kenttä",
"filters": {
"addHeader": "Lisää otsake",
"addPattern": "Lisää kuvio",
"enableFiltering": "Ota sisällön suodatus käyttöön",
"enableFilteringDesc": "Kun käytössä, vain alla olevia sääntöjä vastaavat sähköpostit arkistoidaan. Kun poissa käytöstä, kaikki sähköpostit arkistoidaan.",
"exclude": "Poissulje",
"include": "Sisällytä",
"matchType": {
"contains": "Sisältää",
"endsWith": "Päättyy merkkeihin",
"isExactly": "On täsmälleen",
"regex": "Säännöllinen lauseke (Regex)",
"startsWith": "Alkaa merkeillä"
},
"noExcludePatterns": "Ei poissulkemiskuvioita mitään ei poissuljeta.",
"noIncludePatterns": "Ei sisällytyskuvioita kaikki sisällytetään.",
"noLimit": "Ei rajoitusta",
"noSpamHeaders": "Spam-otsakkeita ei ole määritetty.",
"senderFilter": "Lähettäjän suodatin",
"senderFilterHelp": "Suodata sähköpostit lähettäjän osoitteen perusteun. Sisällytyskuviot sallivat lähettäjät, poissulkemiskuviot hylkäävät ne.",
"sizeLimit": "Koko-rajoitus",
"sizeLimitDesc": "Tätä suuremmat sähköpostit ohitetaan arkistoinnin aikana. Jätä tyhjäksi, jos et halua kokorajoitusta.",
"skipLargerThan": "Ohita sähköpostit, jotka ovat suurempia kuin",
"spamHeaders": "Spam-otsakkeet",
"spamHeadersHelp": "Sähköpostit, joiden otsakkeena on 'yes' tai 'true', ohitetaan. Yleisimmät roskapostin tunnistusotsakkeet näkyvät alla.",
"subjectFilter": "Aiheen suodatin",
"subjectFilterHelp": "Suodata sähköpostit aiherivin perusteella. Toimii samalla tavalla kuin lähettäjän suodatus.",
"suggestions": "Ehdotukset (klikkaa lisätäksesi):"
},
"fixed": "Kiinteä",
"folderSync": {
"autoSelectDescendants": "Valitse alisolmut automaattisesti",
@@ -284,9 +320,25 @@
"selectUnit": "Valitse yksikkö",
"selectedMailboxes": "Valitut postilaatikot",
"serverConfiguration": "Palvelinmääritys (IMAP)",
"settings": {
"download": "Lataus",
"downloadDesc": "Määritä, milloin ja miten sähköpostit haetaan palvelimelta.",
"filters": "Suodattimet",
"filtersDesc": "Hallitse, mitkä sähköpostit arkistoidaan. Kun suodatus on poissa päältä, kaikki sähköpostit tallennetaan.",
"general": "Yleiset",
"generalDesc": "Tilin perustiedot ja tila.",
"newAccount": "Uusi tili",
"performance": "Suorituskyky",
"schedule": "Aikataulu",
"scope": "Laajuus",
"server": "Palvelin",
"serverDesc": "IMAP-yhteysasetukset ja todennus."
},
"since": "alkaen",
"sinceFixed": "Tietystä päivämäärästä lähtien",
"sinceFixedDesc": "Lataa vain valitun päivämäärän jälkeen saapuneet sähköpostit. Sitä edeltävät sähköpostit ohitetaan.",
"sinceRelative": "Lataa vain viimeisimmät sähköpostit",
"sinceRelativeDesc": "Lataa vain viimeaikaiset sähköpostit (esim. viimeiset 3 kuukautta). Aloituspäivämäärä siirtyy automaattisesti eteenpäin.",
"sinceRelativeValue": "Lataa sähköpostit viimeisimmiltä",
"startDownload": "Aloita lataus",
"state": "Tila",
+53 -1
View File
@@ -99,10 +99,12 @@
"areYouSureYouWantTo": "Êtes-vous sûr de vouloir {{action}} ce compte ?",
"auth": "Auth.",
"authType": "type_auth",
"autoConfiguring": "Configuration automatique...",
"autoConfiguring": "Configuration automatique",
"autoDiscover": "Détection automatique des paramètres du serveur",
"autoDownloadNewMailboxes": "Ajouter automatiquement les nouveaux dossiers",
"autoDownloadNewMailboxesDescription": "Ajouter automatiquement les nouveaux dossiers à la liste de téléchargement.",
"beforeRelative": "Télécharger uniquement les anciens e-mails",
"beforeRelativeDesc": "Télécharger uniquement les e-mails antérieurs à la période spécifiée. La date limite s'ajuste automatiquement, idéal pour archiver progressivement les anciens e-mails.",
"beforeRelativeValue": "Télécharger les e-mails d'il y a {{value}} {{unit}}",
"cancelDownload": "Annuler le téléchargement",
"cancelFailed": "Échec de l'annulation de la tâche de téléchargement",
@@ -124,9 +126,13 @@
"cronHour": "Heure",
"cronMinute": "Minute",
"cronMonday": "Lundi",
"cronMonthAlignNote": "Remarque : Si le mois est plus court, l'exécution aura lieu le dernier jour du mois.",
"cronMonthly": "Chaque mois",
"cronSaturday": "Samedi",
"cronSimple": "Expression simple",
"cronSummaryDaily": "Chaque jour à {{hour}}:{{minute}}",
"cronSummaryMonthly": "Le {{day}} de chaque mois à {{hour}}:{{minute}}",
"cronSummaryWeekly": "Chaque {{day}} à {{hour}}:{{minute}}",
"cronSunday": "Dimanche",
"cronThursday": "Jeudi",
"cronTimezoneNote": "Heures au fuseau horaire du serveur.",
@@ -145,6 +151,7 @@
"disableAccount": "Désactiver le compte",
"disabled": "Désactivé",
"downloadAll": "Télécharger tous les e-mails",
"downloadAllDesc": "Tous les e-mails des boîtes aux lettres sélectionnées seront téléchargés. Idéal pour un archivage complet.",
"downloadBatchSize": "Taille du lot de téléchargement",
"downloadBatchSizeDescription": "Nombre de messages récupérés par requête IMAP",
"downloadCancelled": "Tâche annulée",
@@ -176,6 +183,35 @@
"enterValue": "Entrez une valeur",
"everyMinutes": "toutes les {{minutes}} minutes",
"field": "Champ",
"filters": {
"addHeader": "Ajouter un en-tête",
"addPattern": "Ajouter un motif",
"enableFiltering": "Activer le filtrage du contenu",
"enableFilteringDesc": "Lorsqu'il est activé, seuls les e-mails correspondant aux règles ci-dessous seront archivés. S'il est désactivé, tous les e-mails seront archivés.",
"exclude": "Exclure",
"include": "Inclure",
"matchType": {
"contains": "Contient",
"endsWith": "Se termine par",
"isExactly": "Est exactement",
"regex": "Expression régulière (Regex)",
"startsWith": "Commence par"
},
"noExcludePatterns": "Aucun motif d'exclusion — rien n'est exclu.",
"noIncludePatterns": "Aucun motif d'inclusion — tout est inclus.",
"noLimit": "Sans limite",
"noSpamHeaders": "Aucun en-tête de spam configuré.",
"senderFilter": "Filtre d'expéditeur",
"senderFilterHelp": "Filtrez par adresse de l'expéditeur. Les motifs d'inclusion autorisent les expéditeurs ; les motifs d'exclusion les rejettent.",
"sizeLimit": "Limite de taille",
"sizeLimitDesc": "Les e-mails dépassant cette taille seront ignorés lors de l'archivage. Laissez vide pour ne pas fixer de limite.",
"skipLargerThan": "Ignorer les e-mails supérieurs à",
"spamHeaders": "En-têtes de spam",
"spamHeadersHelp": "Les e-mails contenant l'un de ces en-têtes défini sur 'yes' ou 'true' seront ignorés. Des en-têtes de détection de spam courants sont suggérés ci-dessous.",
"subjectFilter": "Filtre d'objet",
"subjectFilterHelp": "Filtrez les e-mails en fonction de l'objet. Fonctionne de la même manière que le filtrage des expéditeurs.",
"suggestions": "Suggestions (cliquer pour ajouter) :"
},
"fixed": "Fixe",
"folderSync": {
"autoSelectDescendants": "Sélectionner automatiquement les descendants",
@@ -284,9 +320,25 @@
"selectUnit": "Sélectionner une unité",
"selectedMailboxes": "Boîtes mail sélectionnées",
"serverConfiguration": "Configuration du Serveur (IMAP)",
"settings": {
"download": "Téléchargement",
"downloadDesc": "Configurer quand et comment les e-mails sont récupérés depuis le serveur.",
"filters": "Filtres",
"filtersDesc": "Contrôlez quels e-mails sont archivés. Si le filtrage est désactivé, tous les e-mails seront enregistrés.",
"general": "Général",
"generalDesc": "Informations de base sur le compte et statut.",
"newAccount": "Nouveau compte",
"performance": "Performances",
"schedule": "Planification",
"scope": "Période",
"server": "Serveur",
"serverDesc": "Paramètres de connexion IMAP et authentification."
},
"since": "depuis",
"sinceFixed": "Depuis une date spécifique",
"sinceFixedDesc": "Télécharger uniquement les e-mails reçus après la date sélectionnée. Les e-mails antérieurs seront ignorés.",
"sinceRelative": "Télécharger uniquement les e-mails récents",
"sinceRelativeDesc": "Télécharger uniquement les e-mails récents (ex. 3 derniers mois). La date de début avance automatiquement.",
"sinceRelativeValue": "Télécharger les e-mails des derniers",
"startDownload": "Lancer le téléchargement",
"state": "État",
+53 -1
View File
@@ -99,10 +99,12 @@
"areYouSureYouWantTo": "Sei sicuro di voler {{action}} questo account?",
"auth": "Autenticazione",
"authType": "tipo_autenticazione",
"autoConfiguring": "Configurazione automatica...",
"autoConfiguring": "Configurazione automatica",
"autoDiscover": "Rilevamento automatico impostazioni server",
"autoDownloadNewMailboxes": "Aggiungi automaticamente nuove cartelle",
"autoDownloadNewMailboxesDescription": "Aggiungi automaticamente le nuove cartelle all'elenco di download.",
"beforeRelative": "Scarica solo le vecchie email",
"beforeRelativeDesc": "Scarica solo le email antecedenti al periodo specificato. La data limite si aggiorna automaticamente, utile per archiviare gradualmente le vecchie email.",
"beforeRelativeValue": "Scarica email da {{value}} {{unit}} fa",
"cancelDownload": "Annulla download",
"cancelFailed": "Annullamento attività di download non riuscito",
@@ -124,9 +126,13 @@
"cronHour": "Ora",
"cronMinute": "Minuto",
"cronMonday": "Lunedì",
"cronMonthAlignNote": "Nota: Se il mese è più breve, verrà eseguito l'ultimo giorno del mese.",
"cronMonthly": "Ogni mese",
"cronSaturday": "Sabato",
"cronSimple": "Espressione semplice",
"cronSummaryDaily": "Ogni giorno alle {{hour}}:{{minute}}",
"cronSummaryMonthly": "Il giorno {{day}} di ogni mese alle {{hour}}:{{minute}}",
"cronSummaryWeekly": "Ogni {{day}} alle {{hour}}:{{minute}}",
"cronSunday": "Domenica",
"cronThursday": "Giovedì",
"cronTimezoneNote": "Orari nel fuso orario del server.",
@@ -145,6 +151,7 @@
"disableAccount": "Disattiva account",
"disabled": "Disabilitato",
"downloadAll": "Scarica tutte le email",
"downloadAllDesc": "Tutte le email in ogni casella di posta selezionata verranno scaricate. Ideale per un archivio completo.",
"downloadBatchSize": "Dimensione del lotto di download",
"downloadBatchSizeDescription": "Numero di messaggi recuperati per richiesta IMAP",
"downloadCancelled": "Attività annullata",
@@ -176,6 +183,35 @@
"enterValue": "Inserisci un valore",
"everyMinutes": "ogni {{minutes}} minuti",
"field": "Campo",
"filters": {
"addHeader": "Aggiungi intestazione",
"addPattern": "Aggiungi pattern",
"enableFiltering": "Attiva il filtraggio dei contenuti",
"enableFilteringDesc": "Se attivato, verranno archiviate solo le email che soddisfano le regole sottostanti. Se disattivato, verranno archiviate tutte le email.",
"exclude": "Escludi",
"include": "Includi",
"matchType": {
"contains": "Contiene",
"endsWith": "Termina con",
"isExactly": "È esattamente",
"regex": "Espressione regolare (Regex)",
"startsWith": "Inizia con"
},
"noExcludePatterns": "Nessun pattern di esclusione — nulla è escluso.",
"noIncludePatterns": "Nessun pattern di inclusione — tutto è incluso.",
"noLimit": "Nessun limite",
"noSpamHeaders": "Nessuna intestazione spam configurata.",
"senderFilter": "Filtro mittente",
"senderFilterHelp": "Filtra le email in base all'indirizzo del mittente. I pattern di inclusione approvano i mittenti, quelli di esclusione li rifiutano.",
"sizeLimit": "Limite di dimensione",
"sizeLimitDesc": "Le email che superano questa dimensione verranno ignorate durante l'archiviazione. Lascia vuoto per nessun limite.",
"skipLargerThan": "Salta le email più grandi di",
"spamHeaders": "Intestazioni spam",
"spamHeadersHelp": "Le email con una di queste intestazioni impostata su 'yes' o 'true' verranno ignorate. Di seguito sono suggerite le intestazioni spam più comuni.",
"subjectFilter": "Filtro oggetto",
"subjectFilterHelp": "Filtra le email in base all'oggetto. Funziona allo stesso modo del filtraggio del mittente.",
"suggestions": "Suggerimenti (clicca per aggiungere):"
},
"fixed": "Fissa",
"folderSync": {
"autoSelectDescendants": "Seleziona automaticamente i discendenti",
@@ -284,9 +320,25 @@
"selectUnit": "Seleziona un'unità",
"selectedMailboxes": "Caselle selezionate",
"serverConfiguration": "Configurazione Server (IMAP)",
"settings": {
"download": "Download",
"downloadDesc": "Configura quando e come le email vengono scaricate dal server.",
"filters": "Filtri",
"filtersDesc": "Controlla quali email archiviare. Quando il filtraggio è disattivato, vengono salvate tutte le email.",
"general": "Generale",
"generalDesc": "Informazioni di base sull'account e stato.",
"newAccount": "Nuovo account",
"performance": "Prestazioni",
"schedule": "Pianificazione",
"scope": "Ambito",
"server": "Server",
"serverDesc": "Impostazioni di connessione IMAP e autenticazione."
},
"since": "da",
"sinceFixed": "Da una data specifica",
"sinceFixedDesc": "Scarica solo le email ricevute dopo la date selezionata. Le email precedenti saranno ignorate.",
"sinceRelative": "Scarica solo le email recenti",
"sinceRelativeDesc": "Scarica solo le email del periodo recente (es. ultimi 3 mesi). La data di inizio si aggiorna automaticamente.",
"sinceRelativeValue": "Scarica email degli ultimi",
"startDownload": "Avvia download",
"state": "Stato",
+53 -1
View File
@@ -99,10 +99,12 @@
"areYouSureYouWantTo": "本当にこのアカウントを{{action}}しますか?",
"auth": "認証",
"authType": "認証タイプ",
"autoConfiguring": "自動設定中...",
"autoConfiguring": "自動設定中",
"autoDiscover": "サーバー設定の自動検出",
"autoDownloadNewMailboxes": "新着フォルダーの自動追加",
"autoDownloadNewMailboxesDescription": "新しく見つかったフォルダーを自動的にダウンロード一覧に追加します。",
"beforeRelative": "古いメールのみダウンロード",
"beforeRelativeDesc": "指定した期間より古いメールのみをダウンロードします。基準日は自動的に推移し、古いメールの段階的なアーカイブに役立ちます。",
"beforeRelativeValue": "{{value}} {{unit}}前より古いメールをダウンロード",
"cancelDownload": "ダウンロードをキャンセル",
"cancelFailed": "ダウンロードタスクのキャンセルに失敗しました",
@@ -124,9 +126,13 @@
"cronHour": "时",
"cronMinute": "分",
"cronMonday": "月曜日",
"cronMonthAlignNote": "注意:指定した日付がその月にない場合は、月末の最終日に実行されます。",
"cronMonthly": "毎月",
"cronSaturday": "土曜日",
"cronSimple": "簡易式",
"cronSummaryDaily": "毎日 {{hour}}:{{minute}}",
"cronSummaryMonthly": "毎月 {{day}} 日 {{hour}}:{{minute}}",
"cronSummaryWeekly": "毎週{{day}} {{hour}}:{{minute}}",
"cronSunday": "日曜日",
"cronThursday": "木曜日",
"cronTimezoneNote": "時間はサーバーの現地時間です。",
@@ -145,6 +151,7 @@
"disableAccount": "アカウントを無効化",
"disabled": "無効",
"downloadAll": "すべてのメールをダウンロード",
"downloadAllDesc": "選択されたメールボックスの全メールを最初から最新までダウンロードします。完全なアーカイブに最適です。",
"downloadBatchSize": "ダウンロードバッチサイズ",
"downloadBatchSizeDescription": "IMAPリクエストごとに取得されるメッセージ数",
"downloadCancelled": "タスクをキャンセルしました",
@@ -176,6 +183,35 @@
"enterValue": "値を入力してください",
"everyMinutes": "{{minutes}}分ごと",
"field": "フィールド",
"filters": {
"addHeader": "ヘッダーを追加",
"addPattern": "パターンを追加",
"enableFiltering": "コンテンツフィルターを有効にする",
"enableFilteringDesc": "有効にすると、以下のルールに一致するメールのみがアーカイブされます。無効にすると、すべてのメールがアーカイブされます。",
"exclude": "除外する",
"include": "含める",
"matchType": {
"contains": "含む",
"endsWith": "後方一致",
"isExactly": "完全一致",
"regex": "正規表現 (Regex)",
"startsWith": "前方一致"
},
"noExcludePatterns": "除外するパターンがありません — 何も除外されません。",
"noIncludePatterns": "含めるパターンがありません — すべて対象になります。",
"noLimit": "制限なし",
"noSpamHeaders": "スパムヘッダーが設定されていません。",
"senderFilter": "送信者フィルター",
"senderFilterHelp": "送信者のメールアドレスでフィルターします。一致する「含めるパターン」は許可され、「除外するパターン」は拒否されます。",
"sizeLimit": "サイズ制限",
"sizeLimitDesc": "このサイズを超えるメールはアーカイブ時にスキップされます。制限しない場合は空欄にしてください。",
"skipLargerThan": "次より大きいサイズをスキップ:",
"spamHeaders": "スパムヘッダー",
"spamHeadersHelp": "これらのヘッダーのいずれかが 'yes' または 'true' に設定されているメールはスキップされます。以下に一般的なスパム検出ヘッダーを提案しています。",
"subjectFilter": "件名フィルター",
"subjectFilterHelp": "メールの件名でフィルターします。送信者フィルターと同じ仕組みで動作します。",
"suggestions": "推奨設定(クリックで追加):"
},
"fixed": "固定",
"folderSync": {
"autoSelectDescendants": "子項目を自動選択",
@@ -284,9 +320,25 @@
"selectUnit": "単位を選択",
"selectedMailboxes": "選択されたメールボックス",
"serverConfiguration": "サーバー設定 (IMAP)",
"settings": {
"download": "ダウンロード設定",
"downloadDesc": "サーバーからメールを取得するタイミングと方法を設定します。",
"filters": "フィルター",
"filtersDesc": "アーカイブ対象のメールを制御します。フィルターを無効にすると、すべてのメールが保存されます。",
"general": "基本情報",
"generalDesc": "アカウントの基本情報とステータス。",
"newAccount": "新規アカウント",
"performance": "パフォーマンス",
"schedule": "時間計画",
"scope": "同期対象期間",
"server": "サーバー設定",
"serverDesc": "IMAP接続設定と認証。"
},
"since": "以降",
"sinceFixed": "指定した日付以降",
"sinceFixedDesc": "選択した日付以降に受信したメールのみをダウンロードし、それ以前のメールは無視します。",
"sinceRelative": "最近のメールのみダウンロード",
"sinceRelativeDesc": "直近の期間(例:過去3ヶ月)のメールのみをダウンロードします。開始日は時間経過に伴い自動的に更新されます。",
"sinceRelativeValue": "直近の期間のメールをダウンロード",
"startDownload": "ダウンロードを開始",
"state": "状態",
+53 -1
View File
@@ -99,10 +99,12 @@
"areYouSureYouWantTo": "이 계정을 정말 {{action}}하시겠습니까?",
"auth": "인증",
"authType": "인증 유형",
"autoConfiguring": "자동 구성 중...",
"autoConfiguring": "자동 설정 중…",
"autoDiscover": "서버 설정 자동 검색",
"autoDownloadNewMailboxes": "새 폴더 자동 추가",
"autoDownloadNewMailboxesDescription": "새로 발견된 폴더를 다운로드 목록에 자동으로 추가합니다.",
"beforeRelative": "이전 이메일만 다운로드",
"beforeRelativeDesc": "지정한 기간보다 오래된 이메일만 다운로드합니다. 기준 날짜는 자동으로 이동하며, 오래된 이메일을 순차적으로 보관할 때 유용합니다.",
"beforeRelativeValue": "{{value}} {{unit}} 전의 이메일 다운로드",
"cancelDownload": "다운로드 취소",
"cancelFailed": "다운로드 작업 취소 실패",
@@ -124,9 +126,13 @@
"cronHour": "시",
"cronMinute": "분",
"cronMonday": "월요일",
"cronMonthAlignNote": "참고: 해당 날짜가 없는 짧은 달의 경우, 해당 월의 마지막 날에 실행됩니다.",
"cronMonthly": "매월",
"cronSaturday": "토요일",
"cronSimple": "간단한 표현식",
"cronSummaryDaily": "매일 {{hour}}:{{minute}}",
"cronSummaryMonthly": "매월 {{day}}일 {{hour}}:{{minute}}",
"cronSummaryWeekly": "매주 {{day}} {{hour}}:{{minute}}",
"cronSunday": "일요일",
"cronThursday": "목요일",
"cronTimezoneNote": "모든 시간은 서버 현지 시간 기준입니다.",
@@ -145,6 +151,7 @@
"disableAccount": "계정 비활성화",
"disabled": "비활성화",
"downloadAll": "모든 이메일 다운로드",
"downloadAllDesc": "선택한 사서함의 모든 이메일을 처음부터 끝까지 다운로드합니다. 전체 보관에 가장 적합합니다.",
"downloadBatchSize": "다운로드 일괄 처리 크기",
"downloadBatchSizeDescription": "IMAP 요청당 가져온 메시지 수",
"downloadCancelled": "작업 취소됨",
@@ -176,6 +183,35 @@
"enterValue": "값 입력",
"everyMinutes": "매 {{minutes}}분",
"field": "필드",
"filters": {
"addHeader": "헤더 추가",
"addPattern": "패턴 추가",
"enableFiltering": "콘텐츠 필터링 활성화",
"enableFilteringDesc": "활성화하면 아래 규칙과 일치하는 이메일만 보관됩니다. 비활성화하면 모든 이메일이 보관됩니다.",
"exclude": "제외",
"include": "포함",
"matchType": {
"contains": "포함",
"endsWith": "끝 문자열",
"isExactly": "정확히 일치",
"regex": "정규 표현식 (Regex)",
"startsWith": "시작 문자열"
},
"noExcludePatterns": "제외 패턴 없음 — 제외되는 항목이 없습니다.",
"noIncludePatterns": "포함 패턴 없음 — 모든 항목이 포함됩니다.",
"noLimit": "제한 없음",
"noSpamHeaders": "설정된 스팸 헤더가 없습니다.",
"senderFilter": "발신자 필터",
"senderFilterHelp": "발신자 주소 기준 필터입니다. 포함 패턴에 일치하면 허용되고, 제외 패턴에 일치하면 차단됩니다.",
"sizeLimit": "용량 제한",
"sizeLimitDesc": "이 크기를 초과하는 이메일은 보관 시 건너뜁니다. 제한하지 않으려면 비워 두세요.",
"skipLargerThan": "다음 용량보다 큰 이메일 건너뛰기:",
"spamHeaders": "스팸 헤더",
"spamHeadersHelp": "이러한 헤더 중 하나라도 'yes' 또는 'true'로 설정된 이메일은 건너뜁니다. 자주 사용되는 스팸 감지 헤더가 아래에 제안되어 있습니다.",
"subjectFilter": "제목 필터",
"subjectFilterHelp": "이메일 제목 기준 필터입니다. 발신자 필터링과 동일한 방식으로 작동합니다.",
"suggestions": "추천 항목 (클릭하여 추가):"
},
"fixed": "고정",
"folderSync": {
"autoSelectDescendants": "하위 항목 자동 선택",
@@ -284,9 +320,25 @@
"selectUnit": "단위 선택",
"selectedMailboxes": "선택된 메일함",
"serverConfiguration": "서버 구성 (IMAP)",
"settings": {
"download": "다운로드 설정",
"downloadDesc": "서버에서 이메일을 가져오는 시기와 방법을 설정합니다.",
"filters": "필터",
"filtersDesc": "보관할 이메일을 제어합니다. 필터링을 비활성화하면 모든 이메일이 저장됩니다.",
"general": "기본 정보",
"generalDesc": "기본 계정 정보 및 상태입니다.",
"newAccount": "새 계정",
"performance": "성능",
"schedule": "시간 계획",
"scope": "동기화 범위",
"server": "서버 설정",
"serverDesc": "IMAP 연결 설정 및 인증입니다."
},
"since": "이후",
"sinceFixed": "특정 날짜 이후",
"sinceFixedDesc": "선택한 날짜 이후에 수신된 이메일만 다운로드하며, 그 이전 이메일은 무시합니다.",
"sinceRelative": "최근 이메일만 다운로드",
"sinceRelativeDesc": "최근 기간(예: 지난 3개월)의 이메일만 다운로드합니다. 시작 날짜는 시간이 지남에 따라 자동으로 이동합니다.",
"sinceRelativeValue": "최근 기간의 이메일 다운로드",
"startDownload": "다운로드 시작",
"state": "상태",
+53 -1
View File
@@ -99,10 +99,12 @@
"areYouSureYouWantTo": "Weet je zeker dat je dit account wilt {{action}}?",
"auth": "Authenticatie",
"authType": "authenticatie_type",
"autoConfiguring": "Automatisch configureren...",
"autoConfiguring": "Automatisch configureren",
"autoDiscover": "Serverinstellingen automatisch detecteren",
"autoDownloadNewMailboxes": "Nieuwe mappen automatisch importeren",
"autoDownloadNewMailboxesDescription": "Voeg automatisch nieuw ontdekte mappen toe aan de downloadlijst.",
"beforeRelative": "Download alleen oude e-mails",
"beforeRelativeDesc": "Download alleen e-mails die ouder zijn dan de opgegeven periode. De limietdatum verschuift automatisch handig voor het geleidelijk archiveren van oude e-mails.",
"beforeRelativeValue": "Download e-mails van {{value}} {{unit}} geleden",
"cancelDownload": "Download annuleren",
"cancelFailed": "Downloadtaak annuleren mislukt",
@@ -124,9 +126,13 @@
"cronHour": "Uur",
"cronMinute": "Minuut",
"cronMonday": "Maandag",
"cronMonthAlignNote": "Opmerking: Als de maand korter is, wordt dit op de laatste dag van de maand uitgevoerd.",
"cronMonthly": "Maandelijks",
"cronSaturday": "Zaterdag",
"cronSimple": "Simpele expressie",
"cronSummaryDaily": "Elke dag om {{hour}}:{{minute}}",
"cronSummaryMonthly": "Op dag {{day}} van elke maand om {{hour}}:{{minute}}",
"cronSummaryWeekly": "Elke {{day}} om {{hour}}:{{minute}}",
"cronSunday": "Zondag",
"cronThursday": "Donderdag",
"cronTimezoneNote": "Alle tijden zijn server-lokale tijd.",
@@ -145,6 +151,7 @@
"disableAccount": "Account uitschakelen",
"disabled": "Uitgeschakeld",
"downloadAll": "Download alle e-mails",
"downloadAllDesc": "Alle e-mails in elke geselecteerde mailbox worden gedownload. Best voor volledige archivering.",
"downloadBatchSize": "Download batchgrootte",
"downloadBatchSizeDescription": "Aantal berichten opgehaald per IMAP-verzoek",
"downloadCancelled": "Taak geannuleerd",
@@ -176,6 +183,35 @@
"enterValue": "Voer een waarde in",
"everyMinutes": "elke {{minutes}} minuten",
"field": "Veld",
"filters": {
"addHeader": "Header toevoegen",
"addPattern": "Patroon toevoegen",
"enableFiltering": "Inhoudsfiltering inschakelen",
"enableFilteringDesc": "Indien ingeschakeld, worden alleen e-mails die aan de onderstaande regels voldoen gearchiveerd. Indien uitgeschakeld, worden alle e-mails gearchiveerd.",
"exclude": "Exclusief",
"include": "Inclusief",
"matchType": {
"contains": "Bevat",
"endsWith": "Eindigt met",
"isExactly": "Is exact",
"regex": "Reguliere expressie (Regex)",
"startsWith": "Begint met"
},
"noExcludePatterns": "Geen exclusiepatronen — niets is uitgesloten.",
"noIncludePatterns": "Geen inclusiepatronen — alles is inbegrepen.",
"noLimit": "Geen limiet",
"noSpamHeaders": "Geen spamheaders geconfigureerd.",
"senderFilter": "Afzenderfilter",
"senderFilterHelp": "Filter e-mails op afzender. Inclusiepatronen laten afzenders door, exclusiepatronen weigeren overeenkomende afzenders.",
"sizeLimit": "Groottebeperking",
"sizeLimitDesc": "E-mails die groter zijn dan deze omvang worden overgeslagen tijdens het archiveren. Laat leeg voor geen limiet.",
"skipLargerThan": "E-mails overslaan die groter zijn dan",
"spamHeaders": "Spamheaders",
"spamHeadersHelp": "E-mails waarbij een van deze headers is ingesteld na 'yes' of 'true' worden overgeslagen. Algemene spamheaders worden hieronder gesuggereerd.",
"subjectFilter": "Onderwerpfilter",
"subjectFilterHelp": "Filter e-mails op basis van de onderwerpregel. Werkt op dezelfde manier als afzenderfiltering.",
"suggestions": "Suggesties (klik om toe te voegen):"
},
"fixed": "Vast",
"folderSync": {
"autoSelectDescendants": "Automatisch onderliggende items selecteren",
@@ -284,9 +320,25 @@
"selectUnit": "Selecteer een eenheid",
"selectedMailboxes": "Geselecteerde mailboxen",
"serverConfiguration": "Serverconfiguratie (IMAP)",
"settings": {
"download": "Downloaden",
"downloadDesc": "Configureren wanneer en hoe e-mails van de server worden opgehaald.",
"filters": "Filters",
"filtersDesc": "Bepaal welke e-mails worden gearchiveerd. Als filtering is uitgeschakeld, worden alle e-mails opgeslagen.",
"general": "Algemeen",
"generalDesc": "Basisaccountinformatie en status.",
"newAccount": "Nieuw account",
"performance": "Prestaties",
"schedule": "Tijdschema",
"scope": "Bereik",
"server": "Server",
"serverDesc": "IMAP-verbindinginstellingen en authenticatie."
},
"since": "sinds",
"sinceFixed": "Sinds een specifieke datum",
"sinceFixedDesc": "Download alleen e-mails ontvangen na de geselecteerde datum. E-mails van vóór die datum worden genegeerd.",
"sinceRelative": "Download alleen recente e-mails",
"sinceRelativeDesc": "Download alleen e-mails uit de afgelopen periode (bijv. laatste 3 maanden). De startdatum verschuift automatisch mee.",
"sinceRelativeValue": "Download e-mails van de laatste",
"startDownload": "Download starten",
"state": "Status",
+53 -1
View File
@@ -99,10 +99,12 @@
"areYouSureYouWantTo": "Er du sikker på at du vil {{action}} denne kontoen?",
"auth": "Autentisering",
"authType": "autentiseringstype",
"autoConfiguring": "Konfigurerer automatisk...",
"autoConfiguring": "Konfigurerer automatisk",
"autoDiscover": "Finn serverinnstillinger automatisk",
"autoDownloadNewMailboxes": "Legg til nye mapper automatisk",
"autoDownloadNewMailboxesDescription": "Legg automatisk til nye mapper i nedlastingslisten.",
"beforeRelative": "Last ned kun gamle e-poster",
"beforeRelativeDesc": "Last bare ned e-poster som er eldre enn den angitte perioden. Skjæringsdatoen flyttes automatisk nyttig for gradvis arkivering.",
"beforeRelativeValue": "Last ned e-poster fra {{value}} {{unit}} siden",
"cancelDownload": "Avbryt nedlasting",
"cancelFailed": "Kunne ikke avbryte nedlastingsoppgave",
@@ -124,9 +126,13 @@
"cronHour": "Time",
"cronMinute": "Minutt",
"cronMonday": "Mandag",
"cronMonthAlignNote": "Merk: Hvis måneden er kortere, vil den bli utført på månedens siste dag.",
"cronMonthly": "Månedlig",
"cronSaturday": "Lørdag",
"cronSimple": "Enkelt uttrykk",
"cronSummaryDaily": "Hver dag kl. {{hour}}:{{minute}}",
"cronSummaryMonthly": "Den {{day}}. i hver måned kl. {{hour}}:{{minute}}",
"cronSummaryWeekly": "Hver {{day}} kl. {{hour}}:{{minute}}",
"cronSunday": "Søndag",
"cronThursday": "Torsdag",
"cronTimezoneNote": "Alle klokkeslett er serverens lokaltid.",
@@ -145,6 +151,7 @@
"disableAccount": "Deaktiver konto",
"disabled": "Deaktivert",
"downloadAll": "Last ned alle e-poster",
"downloadAllDesc": "Alle e-poster i hver valgte postkasse vil bli lastet ned. Best for komplett arkivering.",
"downloadBatchSize": "Nedlastingsbatchstørrelse",
"downloadBatchSizeDescription": "Antall meldinger hentet per IMAP-forespørsel",
"downloadCancelled": "Oppgave avbrutt",
@@ -176,6 +183,35 @@
"enterValue": "Vennligst skriv inn en verdi",
"everyMinutes": "hvert {{minutes}} minutt",
"field": "Felt",
"filters": {
"addHeader": "Legg til header",
"addPattern": "Legg til mønster",
"enableFiltering": "Aktiver innholdsfiltrering",
"enableFilteringDesc": "Når aktivert, vil bare e-poster som samsvarer med reglene nedenfor bli arkivert. Når deaktivert, arkiveres alle e-poster.",
"exclude": "Ekskluder",
"include": "Inkluder",
"matchType": {
"contains": "Inneholder",
"endsWith": "Ender med",
"isExactly": "Er nøyaktig",
"regex": "Regulært uttrykk (Regex)",
"startsWith": "Starter med"
},
"noExcludePatterns": "Ingen ekskluderingsmønstre ingenting er ekskludert.",
"noIncludePatterns": "Ingen inkluderingsmønstre alt er inkludert.",
"noLimit": "Ingen grense",
"noSpamHeaders": "Ingen spam-headere er konfigurert.",
"senderFilter": "Afsenderfilter",
"senderFilterHelp": "Filtrer e-poster basert på avsenderadresse. Inkluderingsmønstre tillater avsendere, ekskluderingsmønstre avviser dem.",
"sizeLimit": "Størrelsesbegrensning",
"sizeLimitDesc": "E-poster større enn denne størrelsen vil bli hoppet over under arkivering. La stå tom for ingen grense.",
"skipLargerThan": "Hopp over e-poster større enn",
"spamHeaders": "Spam-headere",
"spamHeadersHelp": "E-poster med en av disse headerne satt til 'yes' eller 'true' vil bli hoppet over. Vanlige spam-headere er foreslått nedenfor.",
"subjectFilter": "Emnefilter",
"subjectFilterHelp": "Filtrer e-poster basert på emnelinjen. Fungerer på samme måte som avsenderfiltrering.",
"suggestions": "Forslag (klikk for å legge til):"
},
"fixed": "Fast",
"folderSync": {
"autoSelectDescendants": "Velg etterkommere automatisk",
@@ -284,9 +320,25 @@
"selectUnit": "Velg en enhet",
"selectedMailboxes": "Valgte postbokser",
"serverConfiguration": "Serverkonfigurasjon (IMAP)",
"settings": {
"download": "Nedlasting",
"downloadDesc": "Konfigurer når og hvordan e-poster hentes fra serveren.",
"filters": "Filtre",
"filtersDesc": "Styr hvilke e-poster som arkiveres. Når filtrering er deaktivert, lagres alle e-poster.",
"general": "Generelt",
"generalDesc": "Grunnleggende kontoinformasjon og status.",
"newAccount": "Ny konto",
"performance": "Ytelse",
"schedule": "Tidsplan",
"scope": "Omfang",
"server": "Server",
"serverDesc": "IMAP-tilkoblingsinnstillinger og autentisering."
},
"since": "siden",
"sinceFixed": "Siden spesifikk dato",
"sinceFixedDesc": "Last bare ned e-poster mottatt etter valgt dato. E-poster før denne datoen ignoreres.",
"sinceRelative": "Last ned kun nylige e-poster",
"sinceRelativeDesc": "Last bare ned e-poster fra den siste perioden (f.eks. siste 3 måneder). Startdatoen flyttes automatisk fremover.",
"sinceRelativeValue": "Last ned e-poster fra de siste",
"startDownload": "Start nedlasting",
"state": "Tilstand",
+53 -1
View File
@@ -99,10 +99,12 @@
"areYouSureYouWantTo": "Czy na pewno chcesz {{action}} dla tego konta?",
"auth": "Auth",
"authType": "auth_type",
"autoConfiguring": "Auto konfiguracja...",
"autoConfiguring": "Automatyczna konfiguracja",
"autoDiscover": "Automatyczne wykrywanie ustawień serwera",
"autoDownloadNewMailboxes": "Automatycznie dodawaj nowe foldery",
"autoDownloadNewMailboxesDescription": "Automatycznie dodawaj nowo wykryte foldery do listy pobierania.",
"beforeRelative": "Pobierz tylko stare e-maile",
"beforeRelativeDesc": "Pobieraj tylko wiadomości starsze niż określony czas. Data graniczna automatycznie przesuwa się w czasie przydatne do stopniowej archiwizacji starych wiadomości.",
"beforeRelativeValue": "Pobierz e-maile sprzed {{value}} {{unit}}",
"cancelDownload": "Anuluj pobieranie",
"cancelFailed": "Nie udało się anulować zadania pobierania",
@@ -124,9 +126,13 @@
"cronHour": "Godzina",
"cronMinute": "Minuta",
"cronMonday": "Poniedziałek",
"cronMonthAlignNote": "Uwaga: Jeśli miesiąc jest krótszy, wykonanie nastąpi w ostatnim dniu miesiąca.",
"cronMonthly": "Co miesiąc",
"cronSaturday": "Sobota",
"cronSimple": "Proste wyrażenie",
"cronSummaryDaily": "Codziennie o {{hour}}:{{minute}}",
"cronSummaryMonthly": "Każdego {{day}}. dnia miesiąca o {{hour}}:{{minute}}",
"cronSummaryWeekly": "W każdy(ą) {{day}} o {{hour}}:{{minute}}",
"cronSunday": "Niedziela",
"cronThursday": "Czwartek",
"cronTimezoneNote": "Czas według lokalnej strefy serwera.",
@@ -145,6 +151,7 @@
"disableAccount": "Wyłącz konto",
"disabled": "Wyłączone",
"downloadAll": "Pobierz wszystkie e-maile",
"downloadAllDesc": "Wszystkie wiadomości w wybranych skrzynkach zostaną pobrane. Najlepsze do kompletnej archiwizacji.",
"downloadBatchSize": "Rozmiar partii pobierania",
"downloadBatchSizeDescription": "Liczba wiadomości pobieranych na żądanie IMAP",
"downloadCancelled": "Zadanie anulowane",
@@ -176,6 +183,35 @@
"enterValue": "Wpisz wartość",
"everyMinutes": "co {{minutes}} minut",
"field": "Pole",
"filters": {
"addHeader": "Dodaj nagłówek",
"addPattern": "Dodaj regułę",
"enableFiltering": "Włącz filtrowanie zawartości",
"enableFilteringDesc": "Po włączeniu archiwizowane będą tylko wiadomości spełniające poniższe reguły. Po wyłączeniu archiwizowane są wszystkie wiadomości.",
"exclude": "Wyklucz",
"include": "Uwzględnij",
"matchType": {
"contains": "Zawiera",
"endsWith": "Kończy się na",
"isExactly": "Równa się",
"regex": "Wyrażenie regularne (Regex)",
"startsWith": "Zaczyna się od"
},
"noExcludePatterns": "Brak reguł wykluczania — nic nie zostanie wykluczone.",
"noIncludePatterns": "Brak reguł uwzględniania — wszystko zostanie uwzględnione.",
"noLimit": "Bez limitu",
"noSpamHeaders": "Nie skonfigurowano nagłówków spamu.",
"senderFilter": "Filtr nadawcy",
"senderFilterHelp": "Filtruj wiadomości według adresu nadawcy. Reguły uwzględniania przepuszczają nadawców, reguły wykluczania ich odrzucają.",
"sizeLimit": "Limit rozmiaru",
"sizeLimitDesc": "Wiadomości przekraczające ten rozmiar zostaną pominięte podczas archiwizacji. Pozostaw puste, aby nie nakładać limitu.",
"skipLargerThan": "Pomiń wiadomości większe niż",
"spamHeaders": "Nagłówki spamu",
"spamHeadersHelp": "Wiadomości z dowolnym z tych nagłówków ustawionym na 'yes' lub 'true' zostaną pominięte. Poniżej sugerowane są popularne nagłówki spamu.",
"subjectFilter": "Filtr tematu",
"subjectFilterHelp": "Filtruj wiadomości według tematu. Działa w ten sam sposób, co filtrowanie nadawców.",
"suggestions": "Sugerowane (kliknij, aby dodać):"
},
"fixed": "Dokładnie",
"folderSync": {
"autoSelectDescendants": "Automatyczny wybór dzieci (podrzędnych)",
@@ -284,9 +320,25 @@
"selectUnit": "Zaznacz jednostki",
"selectedMailboxes": "Wybrane skrzynki",
"serverConfiguration": "Konfiguracja serwera (IMAP)",
"settings": {
"download": "Pobieranie",
"downloadDesc": "Konfiguruj, kiedy i jak wiadomości e-mail są pobierane z serwera.",
"filters": "Filtry",
"filtersDesc": "Kontroluj, które wiadomości są archiwizowane. Gdy filtrowanie jest wyłączone, zapisywane są wszystkie wiadomości.",
"general": "Ogólne",
"generalDesc": "Podstawowe informacje o koncie i jego status.",
"newAccount": "Nowe konto",
"performance": "Wydajność",
"schedule": "Harmonogram",
"scope": "Zakres",
"server": "Serwer",
"serverDesc": "Ustawienia połączenia IMAP i uwierzytelnianie."
},
"since": "od",
"sinceFixed": "Od konkretnej daty",
"sinceFixedDesc": "Pobieraj tylko wiadomości odebrane po wybranej dacie. Wiadomości sprzed tej daty będą ignorowane.",
"sinceRelative": "Pobierz tylko ostatnie e-maile",
"sinceRelativeDesc": "Pobieraj tylko wiadomości z ostatniego okresu (np. ostatnie 3 miesiące). Data początkowa automatycznie przesuwa się w czasie.",
"sinceRelativeValue": "Pobierz e-maile z ostatnich",
"startDownload": "Uruchom pobieranie",
"state": "Status",
+53 -1
View File
@@ -99,10 +99,12 @@
"areYouSureYouWantTo": "Tem certeza de que deseja {{action}} esta conta?",
"auth": "Autenticação",
"authType": "Tipo de Autenticação",
"autoConfiguring": "Configurando Automaticamente...",
"autoConfiguring": "Configurando automaticamente",
"autoDiscover": "Autodetectar configurações do servidor",
"autoDownloadNewMailboxes": "Adicionar automaticamente novas pastas",
"autoDownloadNewMailboxesDescription": "Adicionar automaticamente novas pastas à lista de download.",
"beforeRelative": "Baixar apenas e-mails antigos",
"beforeRelativeDesc": "Baixar apenas e-mails anteriores ao período especificado. A data limite avança automaticamente com o tempo — útil para arquivar e-mails antigos gradualmente.",
"beforeRelativeValue": "Baixar e-mails de {{value}} {{unit}} atrás",
"cancelDownload": "Cancelar download",
"cancelFailed": "Falha ao cancelar tarefa de download",
@@ -124,9 +126,13 @@
"cronHour": "Hora",
"cronMinute": "Minuto",
"cronMonday": "Segunda-feira",
"cronMonthAlignNote": "Nota: Se o mês for mais curto, a execução ocorrerá no último dia do mês.",
"cronMonthly": "Mensalmente",
"cronSaturday": "Sábado",
"cronSimple": "Expressão simples",
"cronSummaryDaily": "Todos os dias às {{hour}}:{{minute}}",
"cronSummaryMonthly": "No dia {{day}} de cada mês às {{hour}}:{{minute}}",
"cronSummaryWeekly": "Toda(o) {{day}} às {{hour}}:{{minute}}",
"cronSunday": "Domingo",
"cronThursday": "Quinta-feira",
"cronTimezoneNote": "Horários no fuso horário do servidor.",
@@ -145,6 +151,7 @@
"disableAccount": "Desativar conta",
"disabled": "Desativado",
"downloadAll": "Baixar todos os e-mails",
"downloadAllDesc": "Todos os e-mails de cada caixa de correio selecionada serão baixados. Ideal para arquivamento completo.",
"downloadBatchSize": "Tamanho do lote de download",
"downloadBatchSizeDescription": "Número de mensagens recuperadas por solicitação IMAP",
"downloadCancelled": "Tarefa cancelada",
@@ -176,6 +183,35 @@
"enterValue": "Insira o Valor",
"everyMinutes": "A cada {{minutes}} minutos",
"field": "Campo",
"filters": {
"addHeader": "Adicionar cabeçalho",
"addPattern": "Adicionar padrão",
"enableFiltering": "Ativar filtragem de conteúdo",
"enableFilteringDesc": "Quando ativado, apenas os e-mails que correspondem às regras abaixo serão arquivados. Quando desativado, todos os e-mails são arquivados.",
"exclude": "Excluir",
"include": "Incluir",
"matchType": {
"contains": "Contém",
"endsWith": "Termina com",
"isExactly": "É exatamente",
"regex": "Expressão regular (Regex)",
"startsWith": "Começa com"
},
"noExcludePatterns": "Sem padrões de exclusão — nada está excluído.",
"noIncludePatterns": "Sem padrões de inclusão — tudo está incluído.",
"noLimit": "Sem limite",
"noSpamHeaders": "Nenhum cabeçalho de spam configurado.",
"senderFilter": "Filtro de remetente",
"senderFilterHelp": "Filtre e-mails pelo endereço do remetente. Padrões de inclusão aprovam os remetentes; padrões de exclusão rejeitam os correspondentes.",
"sizeLimit": "Limite de tamanho",
"sizeLimitDesc": "E-mails maiores que este tamanho serão ignorados durante o arquivamento. Deixe em branco para não limitar.",
"skipLargerThan": "Ignorar e-mails maiores que",
"spamHeaders": "Cabeçalhos de spam",
"spamHeadersHelp": "E-mails com qualquer um desses cabeçalhos definidos como 'yes' ou 'true' serão ignorados. Cabeçalhos comuns de detecção de spam são sugeridos abaixo.",
"subjectFilter": "Filtro de assunto",
"subjectFilterHelp": "Filtre e-mails com base na linha de assunto. Funciona da mesma forma que a filtragem de remetentes.",
"suggestions": "Sugestões (clique para adicionar):"
},
"fixed": "Fixo",
"folderSync": {
"autoSelectDescendants": "Selecionar automaticamente os descendentes",
@@ -284,9 +320,25 @@
"selectUnit": "Selecionar Unidade",
"selectedMailboxes": "Caixas de correio selecionadas",
"serverConfiguration": "Configuração do Servidor (IMAP)",
"settings": {
"download": "Download",
"downloadDesc": "Configure quando e como os e-mails são buscados no servidor.",
"filters": "Filtros",
"filtersDesc": "Controle quais e-mails serão arquivados. Quando a filtragem está desativada, todos os e-mails são salvos.",
"general": "Geral",
"generalDesc": "Informações básicas da conta e status.",
"newAccount": "Nova conta",
"performance": "Desempenho",
"schedule": "Cronograma",
"scope": "Escopo",
"server": "Servidor",
"serverDesc": "Configurações de conexão IMAP e autenticação."
},
"since": "Desde",
"sinceFixed": "Desde uma data específica",
"sinceFixedDesc": "Baixar apenas e-mails recebidos após a data selecionada. E-mails anteriores serão ignorados.",
"sinceRelative": "Baixar apenas e-mails recentes",
"sinceRelativeDesc": "Baixar apenas e-mails do período recente (ex: últimos 3 meses). A data de início avança automaticamente com o tempo.",
"sinceRelativeValue": "Baixar e-mails dos últimos",
"startDownload": "Iniciar download",
"state": "Estado",
+53 -1
View File
@@ -99,10 +99,12 @@
"areYouSureYouWantTo": "Вы уверены, что хотите {{action}} этот аккаунт?",
"auth": "Авторизация",
"authType": "тип_авторизации",
"autoConfiguring": "Автонастройка...",
"autoConfiguring": "Автоматическая настройка",
"autoDiscover": "Автоопределение настроек сервера",
"autoDownloadNewMailboxes": "Автодобавление новых папок",
"autoDownloadNewMailboxesDescription": "Автоматически добавлять новые папки в список загрузки.",
"beforeRelative": "Скачать только старые письма",
"beforeRelativeDesc": "Скачивать только письма старше указанного периода. Дата отсечки автоматически сдвигается со временем — полезно для постепенного архивирования старых писем.",
"beforeRelativeValue": "Скачать письма за {{value}} {{unit}} назад",
"cancelDownload": "Отменить загрузку",
"cancelFailed": "Не удалось отменить задачу загрузки",
@@ -124,9 +126,13 @@
"cronHour": "Час",
"cronMinute": "Минута",
"cronMonday": "Понедельник",
"cronMonthAlignNote": "Примечание: Если в месяце меньше дней, выполнение произойдет в последний день месяца.",
"cronMonthly": "Ежемесячно",
"cronSaturday": "Суббота",
"cronSimple": "Простое выражение",
"cronSummaryDaily": "Каждый день в {{hour}}:{{minute}}",
"cronSummaryMonthly": "{{day}}-го числа каждого месяца в {{hour}}:{{minute}}",
"cronSummaryWeekly": "Каждую(ый) {{day}} в {{hour}}:{{minute}}",
"cronSunday": "Воскресенье",
"cronThursday": "Четверг",
"cronTimezoneNote": "Время по местному часовому поясу сервера.",
@@ -145,6 +151,7 @@
"disableAccount": "Отключить аккаунт",
"disabled": "Отключено",
"downloadAll": "Скачать все письма",
"downloadAllDesc": "Все письма в выбранных ящиках будут скачаны от первых до последних. Идеально для полного архивирования.",
"downloadBatchSize": "Размер пакета загрузки",
"downloadBatchSizeDescription": "Количество сообщений, получаемых за один IMAP-запрос",
"downloadCancelled": "Задача отменена",
@@ -176,6 +183,35 @@
"enterValue": "Пожалуйста, введите значение",
"everyMinutes": "каждые {{minutes}} мин.",
"field": "Поле",
"filters": {
"addHeader": "Добавить заголовок",
"addPattern": "Добавить шаблон",
"enableFiltering": "Включить фильтрацию контента",
"enableFilteringDesc": "Если включено, будут архивироваться только письма, соответствующие правилам ниже. Если отключено — архивируются все письма.",
"exclude": "Исключить",
"include": "Включить",
"matchType": {
"contains": "Содержит",
"endsWith": "Заканчивается на",
"isExactly": "Точное совпадение",
"regex": "Регулярное выражение (Regex)",
"startsWith": "Начинается с"
},
"noExcludePatterns": "Нет шаблонов исключения — ничто не исключено.",
"noIncludePatterns": "Нет шаблонов включения — включено всё.",
"noLimit": "Без ограничений",
"noSpamHeaders": "Заголовки спама не настроены.",
"senderFilter": "Фильтр отправителей",
"senderFilterHelp": "Фильтрация по адресу отправителя. Шаблоны включения пропускают отправителей, а шаблоны исключения — отклоняют.",
"sizeLimit": "Лимит по размеру",
"sizeLimitDesc": "Письма крупнее этого размера будут пропущены при архивации. Оставьте пустым, чтобы не ограничивать размер.",
"skipLargerThan": "Пропускать письма крупнее",
"spamHeaders": "Заголовки спама",
"spamHeadersHelp": "Письма, у которых любой из этих заголовков имеет значение 'yes' или 'true', будут пропущены. Ниже приведены популярные заголовки для обнаружения спама.",
"subjectFilter": "Фильтр тем",
"subjectFilterHelp": "Фильтрация по теме письма. Работает так же, как и фильтрация отправителей.",
"suggestions": "Рекомендации (нажмите, чтобы добавить):"
},
"fixed": "Фиксированная",
"folderSync": {
"autoSelectDescendants": "Автоматически выбирать дочерние элементы",
@@ -284,9 +320,25 @@
"selectUnit": "Выберите единицу",
"selectedMailboxes": "Выбранные почтовые ящики",
"serverConfiguration": "Конфигурация сервера (IMAP)",
"settings": {
"download": "Скачивание",
"downloadDesc": "Настройка времени и способа получения писем с сервера.",
"filters": "Фильтры",
"filtersDesc": "Управляйте тем, какие письма архивировать. Если фильтрация отключена, будут сохраняться все письма.",
"general": "Общие",
"generalDesc": "Основная информация об аккаунте и его статус.",
"newAccount": "Новый аккаунт",
"performance": "Производительность",
"schedule": "Расписание",
"scope": "Период синхронизации",
"server": "Сервер",
"serverDesc": "Настройки подключения IMAP и аутентификация."
},
"since": "с",
"sinceFixed": "С определенной даты",
"sinceFixedDesc": "Скачивать только письма, полученные после указанной даты. Более ранние письма будут проигнорированы.",
"sinceRelative": "Скачать только недавние письма",
"sinceRelativeDesc": "Скачивать письма только за последний период (напр., за 3 месяца). Дата начала автоматически сдвигается со временем.",
"sinceRelativeValue": "Скачать письма за последние",
"startDownload": "Запустить загрузку",
"state": "Состояние",
+53 -1
View File
@@ -99,10 +99,12 @@
"areYouSureYouWantTo": "Är du säker på att du vill {{action}} detta konto?",
"auth": "Auth",
"authType": "auth_typ",
"autoConfiguring": "Konfigurerar automatiskt...",
"autoConfiguring": "Konfigurerar automatiskt",
"autoDiscover": "Hitta serverinställningar automatiskt",
"autoDownloadNewMailboxes": "Lägg till nya mappar automatiskt",
"autoDownloadNewMailboxesDescription": "Lägg automatiskt till nya mappar i hämtningslistan.",
"beforeRelative": "Ladda ner endast gamla e-postmeddelanden",
"beforeRelativeDesc": "Ladda endast ner e-postmeddelanden som är äldre än den angivna perioden. Brytdatumet flyttas automatiskt användbart för att gradvis arkivera gamla e-postmeddelanden.",
"beforeRelativeValue": "Ladda ner e-post från {{value}} {{unit}} sedan",
"cancelDownload": "Avbryt hämtning",
"cancelFailed": "Misslyckades med att avbryta hämtningsuppgift",
@@ -124,9 +126,13 @@
"cronHour": "Timme",
"cronMinute": "Minut",
"cronMonday": "Måndag",
"cronMonthAlignNote": "Obs: Om månaden är kortare kommer det att utföras på månadens sista dag.",
"cronMonthly": "Månadsvis",
"cronSaturday": "Lördag",
"cronSimple": "Enkelt uttryck",
"cronSummaryDaily": "Varje dag kl. {{hour}}:{{minute}}",
"cronSummaryMonthly": "Den {{day}}:e i varje månad kl. {{hour}}:{{minute}}",
"cronSummaryWeekly": "Varje {{day}} kl. {{hour}}:{{minute}}",
"cronSunday": "Söndag",
"cronThursday": "Torsdag",
"cronTimezoneNote": "Alla tider visas i serverns lokaltid.",
@@ -145,6 +151,7 @@
"disableAccount": "Inaktivera konto",
"disabled": "Inaktiverad",
"downloadAll": "Ladda ner alla e-postmeddelanden",
"downloadAllDesc": "Alla e-postmeddelanden i varje vald brevlåda kommer att laddas ner. Bäst för komplett arkivering.",
"downloadBatchSize": "Batchstorlek för nedladdning",
"downloadBatchSizeDescription": "Antal meddelanden som hämtas per IMAP-begäran",
"downloadCancelled": "Uppgiften avbruten",
@@ -176,6 +183,35 @@
"enterValue": "Ange ett värde",
"everyMinutes": "varje {{minutes}} minut",
"field": "Fält",
"filters": {
"addHeader": "Lägg till rubrik",
"addPattern": "Lägg till mönster",
"enableFiltering": "Aktivera innehållsfiltrering",
"enableFilteringDesc": "När den er aktiverad kommer endast e-postmeddelanden som matchar reglerna nedan att arkiveras. När den är inaktiverad arkiveras alla e-postmeddelanden.",
"exclude": "Exkludera",
"include": "Inkludera",
"matchType": {
"contains": "Innehåller",
"endsWith": "Slutar med",
"isExactly": "Är exakt",
"regex": "Reguljärt uttryck (Regex)",
"startsWith": "Börjar med"
},
"noExcludePatterns": "Inga exkluderingsmönster inget exkluderas.",
"noIncludePatterns": "Inga inkluderingsmönster allt inkluderas.",
"noLimit": "Ingen gräns",
"noSpamHeaders": "Inga spamrubriker har konfigurerats.",
"senderFilter": "Afsendarefilter",
"senderFilterHelp": "Filtrera e-post baserat på avsändaradress. Inkluderingsmönster godkänner avsändare, exkluderingsmönster avvisar dem.",
"sizeLimit": "Storleksgräns",
"sizeLimitDesc": "E-postmeddelanden som är större än denna storlek kommer att hoppas över vid arkivering. Lämna tomt för ingen gräns.",
"skipLargerThan": "Hoppa över e-postmeddelanden större än",
"spamHeaders": "Spamrubriker",
"spamHeadersHelp": "E-postmeddelanden där någon av dessa rubriker är satt till 'yes' eller 'true' kommer att hoppas över. Vanliga spamrubriker föreslås nedan.",
"subjectFilter": "Ämnesfilter",
"subjectFilterHelp": "Filtrera e-post baserat på ämnesraden. Fungerar på samma sätt som avsändarfiltrering.",
"suggestions": "Förslag (klicka för att lägga till):"
},
"fixed": "Fast",
"folderSync": {
"autoSelectDescendants": "Välj underordnade automatiskt",
@@ -284,9 +320,25 @@
"selectUnit": "Välj en enhet",
"selectedMailboxes": "Valda brevlådor",
"serverConfiguration": "Serverkonfiguration (IMAP)",
"settings": {
"download": "Ladda ner",
"downloadDesc": "Konfigurera när och hur e-postmeddelanden hämtas från serveren.",
"filters": "Filter",
"filtersDesc": "Styr vilka e-postmeddelanden som arkiveras. När filtrering är inaktiverad sparas alla e-postmeddelanden.",
"general": "Allmänt",
"generalDesc": "Grundläggande kontoinformation och status.",
"newAccount": "Nytt konto",
"performance": "Prestanda",
"schedule": "Tidsplan",
"scope": "Omfång",
"server": "Server",
"serverDesc": "IMAP-anslutningsinställningar och autentisering."
},
"since": "sedan",
"sinceFixed": "Sedan specifikt datum",
"sinceFixedDesc": "Ladda endast ner e-postmeddelanden som tagits emot efter det valda datumet. Tidigare e-postmeddelanden ignoreras.",
"sinceRelative": "Ladda ner endast senaste e-postmeddelanden",
"sinceRelativeDesc": "Ladda endast ner e-postmeddelanden från den senaste perioden (t.ex. senaste 3 månaderna). Startdatumet flyttas automatiskt framåt.",
"sinceRelativeValue": "Ladda ner e-post från de senaste",
"startDownload": "Starta hämtning",
"state": "Tillstånd",
+53 -1
View File
@@ -99,10 +99,12 @@
"areYouSureYouWantTo": "你確定要{{action}}此帳戶嗎?",
"auth": "驗證",
"authType": "驗證類型",
"autoConfiguring": "正在自動設定...",
"autoConfiguring": "正在自動設定",
"autoDiscover": "自動偵測伺服器設定",
"autoDownloadNewMailboxes": "自動添加新郵件夾",
"autoDownloadNewMailboxesDescription": "自動將新發現的郵件夾添加到下載列表中。",
"beforeRelative": "僅下載舊郵件",
"beforeRelativeDesc": "僅下載早於指定時間段的舊郵件。截止日期會隨時間自動向後推移,適合逐步封存舊郵件並跳過新郵件。",
"beforeRelativeValue": "下載 {{value}} {{unit}} 之前的郵件",
"cancelDownload": "取消下載",
"cancelFailed": "取消下載任務失敗",
@@ -124,9 +126,13 @@
"cronHour": "小時",
"cronMinute": "分鐘",
"cronMonday": "星期一",
"cronMonthAlignNote": "注意:若當月天數不足,將在該月的最後一天執行。",
"cronMonthly": "每月",
"cronSaturday": "星期六",
"cronSimple": "简易表達式",
"cronSummaryDaily": "每天 {{hour}}:{{minute}}",
"cronSummaryMonthly": "每月 {{day}} 日 {{hour}}:{{minute}}",
"cronSummaryWeekly": "每周{{day}} {{hour}}:{{minute}}",
"cronSunday": "星期日",
"cronThursday": "星期四",
"cronTimezoneNote": "所有時間均使用伺服器在地時區。",
@@ -145,6 +151,7 @@
"disableAccount": "停用帳戶",
"disabled": "已停用",
"downloadAll": "下載所有郵件",
"downloadAllDesc": "將下載選取信箱中的所有郵件(從第一封到最新一封)。最適合完整封存。",
"downloadBatchSize": "下載批量大小",
"downloadBatchSizeDescription": "每個 IMAP 請求獲取的郵件數量",
"downloadCancelled": "下載任務已取消",
@@ -176,6 +183,35 @@
"enterValue": "輸入值",
"everyMinutes": "每 {{minutes}} 分鐘",
"field": "欄位",
"filters": {
"addHeader": "新增標頭",
"addPattern": "新增規則",
"enableFiltering": "启用內容篩選",
"enableFilteringDesc": "開啟後,僅封存符合下方規則的郵件;關閉後,將封存所有郵件。",
"exclude": "排除",
"include": "包含",
"matchType": {
"contains": "包含",
"endsWith": "結尾為",
"isExactly": "完全符合",
"regex": "正規表示式",
"startsWith": "開頭為"
},
"noExcludePatterns": "無排除規則 — 無內容被排除。",
"noIncludePatterns": "無包含規則 — 所有內容均將被包含。",
"noLimit": "不限制",
"noSpamHeaders": "未設定垃圾郵件標頭。",
"senderFilter": "寄件者篩選",
"senderFilterHelp": "基於寄件者電子郵件地址進行篩選。符合「包含規則」的允許通過,符合「排除規則」的則予以拒絕。",
"sizeLimit": "大小限制",
"sizeLimitDesc": "超過此大小的郵件在封存時將被跳過。留空則表示不限制大小。",
"skipLargerThan": "跳過大小超過以下值的郵件",
"spamHeaders": "垃圾郵件標頭",
"spamHeadersHelp": "若郵件包含以下任一標頭且值為 'yes' 或 'true',將被自動跳過。下方列出了常用的垃圾郵件偵測標記。",
"subjectFilter": "主旨篩選",
"subjectFilterHelp": "基於郵件主旨進行篩選。運作原理與寄件者篩選相同。",
"suggestions": "▼ 建議標記(點擊新增):"
},
"fixed": "固定",
"folderSync": {
"autoSelectDescendants": "自動選取子項",
@@ -284,9 +320,25 @@
"selectUnit": "選擇單位",
"selectedMailboxes": "已選擇的郵件夾",
"serverConfiguration": "伺服器設定 (IMAP)",
"settings": {
"download": "下載設定",
"downloadDesc": "設定從伺服器獲取與收取郵件的时间与方式。",
"filters": "篩選器",
"filtersDesc": "控制哪些郵件需要封存。關閉篩選時,將儲存所有郵件。",
"general": "基本資訊",
"generalDesc": "基本帳戶資訊与狀態。",
"newAccount": "建立新帳戶",
"performance": "效能",
"schedule": "時間排程",
"scope": "下載範圍",
"server": "伺服器設定",
"serverDesc": "IMAP 連線設定與驗證。"
},
"since": "自",
"sinceFixed": "自特定日期起",
"sinceFixedDesc": "僅下載指定日期之後收到的郵件,此前的郵件將被忽略。",
"sinceRelative": "僅下載最近郵件",
"sinceRelativeDesc": "僅下載最近一段時間(如過去 3 個月)的郵件。開始日期會隨時間推移自動向前滾動。",
"sinceRelativeValue": "下載最近一段時期的郵件",
"startDownload": "啟動下載",
"state": "狀態",
+64 -2
View File
@@ -98,11 +98,14 @@
"allMailSkipped": "已选择标准文件夹。已跳过'所有邮件'以避免重复。",
"areYouSureYouWantTo": "你确定要{{action}}此账户吗?",
"auth": "认证",
"authPassword": "密码",
"authType": "认证类型",
"autoConfiguring": "自动配置中...",
"autoConfiguring": "正在自动配置",
"autoDiscover": "自动检测服务器设置",
"autoDownloadNewMailboxes": "自动添加新邮件夹",
"autoDownloadNewMailboxesDescription": "自动将新发现的邮件夹添加到下载列表中。",
"beforeRelative": "仅下载旧邮件",
"beforeRelativeDesc": "仅下载早于指定时间段的旧邮件。截止日期会随时间自动向后推移,适合逐步归档旧邮件并跳过新邮件。",
"beforeRelativeValue": "下载 {{value}} {{unit}} 之前的邮件",
"cancelDownload": "取消下载",
"cancelFailed": "取消下载任务失败",
@@ -114,19 +117,24 @@
"clickSaveWhenDone": "完成后请点击保存。",
"continue": "继续",
"createdAt": "创建时间",
"creating": "创建中...",
"creationFailed": "创建失败,请稍后重试",
"cronAdvanced": "高级表达式",
"cronDaily": "每天",
"cronDayOfMonth": "星期",
"cronDayOfMonth": "几号",
"cronDayOfWeek": "星期",
"cronFrequency": "频率",
"cronFriday": "星期五",
"cronHour": "小时",
"cronMinute": "分钟",
"cronMonday": "星期一",
"cronMonthAlignNote": "注意:若当月天数不足,将在该月的最后一天执行。",
"cronMonthly": "每月",
"cronSaturday": "星期六",
"cronSimple": "简易表达式",
"cronSummaryDaily": "每天 {{hour}}:{{minute}}",
"cronSummaryMonthly": "每月 {{day}} 日 {{hour}}:{{minute}}",
"cronSummaryWeekly": "每周{{day}} {{hour}}:{{minute}}",
"cronSunday": "星期日",
"cronThursday": "星期四",
"cronTimezoneNote": "所有时间均使用服务器本地时区。",
@@ -145,6 +153,7 @@
"disableAccount": "禁用账户",
"disabled": "已禁用",
"downloadAll": "下载所有邮件",
"downloadAllDesc": "将下载选中邮箱中的所有邮件(从第一封到最新一封)。最适合完整归档。",
"downloadBatchSize": "下载批量大小",
"downloadBatchSizeDescription": "每个 IMAP 请求获取的邮件数量",
"downloadCancelled": "下载任务已取消",
@@ -176,6 +185,35 @@
"enterValue": "请输入一个值",
"everyMinutes": "每 {{minutes}} 分钟",
"field": "字段",
"filters": {
"addHeader": "添加头字段",
"addPattern": "添加规则",
"enableFiltering": "启用内容过滤",
"enableFilteringDesc": "开启后,仅归档符合下方规则的邮件;关闭后,将归档所有邮件。",
"exclude": "排除",
"include": "包含",
"matchType": {
"contains": "包含",
"endsWith": "结尾为",
"isExactly": "完全匹配",
"regex": "正则表达式",
"startsWith": "开头为"
},
"noExcludePatterns": "无排除规则 — 无内容被排除。",
"noIncludePatterns": "无包含规则 — 所有内容均将被包含。",
"noLimit": "不限制",
"noSpamHeaders": "未配置垃圾邮件头字段。",
"senderFilter": "发件人过滤",
"senderFilterHelp": "基于发件人邮箱地址进行过滤。符合“包含规则”的允许通过,符合“排除规则”的则予以拒绝。",
"sizeLimit": "大小限制",
"sizeLimitDesc": "超过此大小的邮件在归档时将被跳过。留空则表示不限制大小。",
"skipLargerThan": "跳过大小超过以下值的邮件",
"spamHeaders": "垃圾邮件头字段",
"spamHeadersHelp": "若邮件包含以下任一头字段且值为 'yes' 或 'true',将被自动跳过。下方列出了常用的垃圾邮件检测标志。",
"subjectFilter": "主题过滤",
"subjectFilterHelp": "基于邮件主题进行过滤。工作原理与发件人过滤相同。",
"suggestions": "推荐配置(点击添加):"
},
"fixed": "固定",
"folderSync": {
"autoSelectDescendants": "自动选择子项",
@@ -284,9 +322,33 @@
"selectUnit": "选择单位",
"selectedMailboxes": "已选择的邮件夹",
"serverConfiguration": "服务器配置 (IMAP)",
"settings": {
"backToAccounts": "返回账户列表",
"download": "下载设置",
"downloadDesc": "配置从服务器获取和收取邮件的时间与方式。",
"filters": "过滤器",
"filtersDesc": "控制哪些邮件需要归档。关闭过滤时,将保存所有邮件。",
"general": "基本信息",
"generalDesc": "基本账户信息与状态。",
"loading": "加载账户中...",
"newAccount": "新建账户",
"performance": "性能",
"reset": "重置",
"save": "保存",
"saved": "已保存",
"savedDesc": "设置已成功保存。",
"saving": "保存中...",
"schedule": "时间计划",
"scope": "下载范围",
"server": "服务器设置",
"serverDesc": "IMAP 连接设置与身份验证。",
"settings": "设置"
},
"since": "自",
"sinceFixed": "自特定日期起",
"sinceFixedDesc": "仅下载指定日期之后收到的邮件,此前的邮件将被忽略。",
"sinceRelative": "仅下载最近邮件",
"sinceRelativeDesc": "仅下载最近一段时间(如过去 3 个月)的邮件。开始日期会随时间推移自动向后滚动。",
"sinceRelativeValue": "下载最近一段时期的邮件",
"startDownload": "启动下载",
"state": "状态",
+68 -1
View File
@@ -76,6 +76,12 @@ const AuthenticatedSettingsApiTokensLazyImport = createFileRoute(
const AuthenticatedSettingsAccessLazyImport = createFileRoute(
'/_authenticated/settings/access',
)()
const AuthenticatedAccountsNewLazyImport = createFileRoute(
'/_authenticated/accounts/new',
)()
const AuthenticatedAccountsIdSettingsLazyImport = createFileRoute(
'/_authenticated/accounts/$id/settings',
)()
// Create/Update Routes
@@ -317,6 +323,26 @@ const AuthenticatedSettingsAccessLazyRoute =
import('./routes/_authenticated/settings/access.lazy').then((d) => d.Route),
)
const AuthenticatedAccountsNewLazyRoute =
AuthenticatedAccountsNewLazyImport.update({
id: '/accounts/new',
path: '/accounts/new',
getParentRoute: () => AuthenticatedRouteRoute,
} as any).lazy(() =>
import('./routes/_authenticated/accounts/new.lazy').then((d) => d.Route),
)
const AuthenticatedAccountsIdSettingsLazyRoute =
AuthenticatedAccountsIdSettingsLazyImport.update({
id: '/accounts/$id/settings',
path: '/accounts/$id/settings',
getParentRoute: () => AuthenticatedRouteRoute,
} as any).lazy(() =>
import('./routes/_authenticated/accounts/$id.settings.lazy').then(
(d) => d.Route,
),
)
// Populate the FileRoutesByPath interface
declare module '@tanstack/react-router' {
@@ -398,6 +424,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedIndexImport
parentRoute: typeof AuthenticatedRouteImport
}
'/_authenticated/accounts/new': {
id: '/_authenticated/accounts/new'
path: '/accounts/new'
fullPath: '/accounts/new'
preLoaderRoute: typeof AuthenticatedAccountsNewLazyImport
parentRoute: typeof AuthenticatedRouteImport
}
'/_authenticated/settings/access': {
id: '/_authenticated/settings/access'
path: '/access'
@@ -517,6 +550,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedUsersIndexLazyImport
parentRoute: typeof AuthenticatedUsersRouteLazyImport
}
'/_authenticated/accounts/$id/settings': {
id: '/_authenticated/accounts/$id/settings'
path: '/accounts/$id/settings'
fullPath: '/accounts/$id/settings'
preLoaderRoute: typeof AuthenticatedAccountsIdSettingsLazyImport
parentRoute: typeof AuthenticatedRouteImport
}
}
}
@@ -574,6 +614,7 @@ interface AuthenticatedRouteRouteChildren {
AuthenticatedSettingsRouteLazyRoute: typeof AuthenticatedSettingsRouteLazyRouteWithChildren
AuthenticatedUsersRouteLazyRoute: typeof AuthenticatedUsersRouteLazyRouteWithChildren
AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute
AuthenticatedAccountsNewLazyRoute: typeof AuthenticatedAccountsNewLazyRoute
AuthenticatedAttachmentIndexRoute: typeof AuthenticatedAttachmentIndexRoute
AuthenticatedImportIndexRoute: typeof AuthenticatedImportIndexRoute
AuthenticatedSearchIndexRoute: typeof AuthenticatedSearchIndexRoute
@@ -581,6 +622,7 @@ interface AuthenticatedRouteRouteChildren {
AuthenticatedApiDocsIndexLazyRoute: typeof AuthenticatedApiDocsIndexLazyRoute
AuthenticatedOauth2ResultIndexLazyRoute: typeof AuthenticatedOauth2ResultIndexLazyRoute
AuthenticatedOauth2IndexLazyRoute: typeof AuthenticatedOauth2IndexLazyRoute
AuthenticatedAccountsIdSettingsLazyRoute: typeof AuthenticatedAccountsIdSettingsLazyRoute
}
const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = {
@@ -589,6 +631,7 @@ const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = {
AuthenticatedUsersRouteLazyRoute:
AuthenticatedUsersRouteLazyRouteWithChildren,
AuthenticatedIndexRoute: AuthenticatedIndexRoute,
AuthenticatedAccountsNewLazyRoute: AuthenticatedAccountsNewLazyRoute,
AuthenticatedAttachmentIndexRoute: AuthenticatedAttachmentIndexRoute,
AuthenticatedImportIndexRoute: AuthenticatedImportIndexRoute,
AuthenticatedSearchIndexRoute: AuthenticatedSearchIndexRoute,
@@ -597,6 +640,8 @@ const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = {
AuthenticatedOauth2ResultIndexLazyRoute:
AuthenticatedOauth2ResultIndexLazyRoute,
AuthenticatedOauth2IndexLazyRoute: AuthenticatedOauth2IndexLazyRoute,
AuthenticatedAccountsIdSettingsLazyRoute:
AuthenticatedAccountsIdSettingsLazyRoute,
}
const AuthenticatedRouteRouteWithChildren =
@@ -613,6 +658,7 @@ export interface FileRoutesByFullPath {
'/404': typeof errors404LazyRoute
'/503': typeof errors503LazyRoute
'/': typeof AuthenticatedIndexRoute
'/accounts/new': typeof AuthenticatedAccountsNewLazyRoute
'/settings/access': typeof AuthenticatedSettingsAccessLazyRoute
'/settings/api-tokens': typeof AuthenticatedSettingsApiTokensLazyRoute
'/settings/appearance': typeof AuthenticatedSettingsAppearanceLazyRoute
@@ -630,6 +676,7 @@ export interface FileRoutesByFullPath {
'/oauth2': typeof AuthenticatedOauth2IndexLazyRoute
'/settings/': typeof AuthenticatedSettingsIndexLazyRoute
'/users/': typeof AuthenticatedUsersIndexLazyRoute
'/accounts/$id/settings': typeof AuthenticatedAccountsIdSettingsLazyRoute
}
export interface FileRoutesByTo {
@@ -640,6 +687,7 @@ export interface FileRoutesByTo {
'/404': typeof errors404LazyRoute
'/503': typeof errors503LazyRoute
'/': typeof AuthenticatedIndexRoute
'/accounts/new': typeof AuthenticatedAccountsNewLazyRoute
'/settings/access': typeof AuthenticatedSettingsAccessLazyRoute
'/settings/api-tokens': typeof AuthenticatedSettingsApiTokensLazyRoute
'/settings/appearance': typeof AuthenticatedSettingsAppearanceLazyRoute
@@ -657,6 +705,7 @@ export interface FileRoutesByTo {
'/oauth2': typeof AuthenticatedOauth2IndexLazyRoute
'/settings': typeof AuthenticatedSettingsIndexLazyRoute
'/users': typeof AuthenticatedUsersIndexLazyRoute
'/accounts/$id/settings': typeof AuthenticatedAccountsIdSettingsLazyRoute
}
export interface FileRoutesById {
@@ -672,6 +721,7 @@ export interface FileRoutesById {
'/(errors)/500': typeof errors500LazyRoute
'/(errors)/503': typeof errors503LazyRoute
'/_authenticated/': typeof AuthenticatedIndexRoute
'/_authenticated/accounts/new': typeof AuthenticatedAccountsNewLazyRoute
'/_authenticated/settings/access': typeof AuthenticatedSettingsAccessLazyRoute
'/_authenticated/settings/api-tokens': typeof AuthenticatedSettingsApiTokensLazyRoute
'/_authenticated/settings/appearance': typeof AuthenticatedSettingsAppearanceLazyRoute
@@ -689,6 +739,7 @@ export interface FileRoutesById {
'/_authenticated/oauth2/': typeof AuthenticatedOauth2IndexLazyRoute
'/_authenticated/settings/': typeof AuthenticatedSettingsIndexLazyRoute
'/_authenticated/users/': typeof AuthenticatedUsersIndexLazyRoute
'/_authenticated/accounts/$id/settings': typeof AuthenticatedAccountsIdSettingsLazyRoute
}
export interface FileRouteTypes {
@@ -704,6 +755,7 @@ export interface FileRouteTypes {
| '/404'
| '/503'
| '/'
| '/accounts/new'
| '/settings/access'
| '/settings/api-tokens'
| '/settings/appearance'
@@ -721,6 +773,7 @@ export interface FileRouteTypes {
| '/oauth2'
| '/settings/'
| '/users/'
| '/accounts/$id/settings'
fileRoutesByTo: FileRoutesByTo
to:
| '/500'
@@ -730,6 +783,7 @@ export interface FileRouteTypes {
| '/404'
| '/503'
| '/'
| '/accounts/new'
| '/settings/access'
| '/settings/api-tokens'
| '/settings/appearance'
@@ -747,6 +801,7 @@ export interface FileRouteTypes {
| '/oauth2'
| '/settings'
| '/users'
| '/accounts/$id/settings'
id:
| '__root__'
| '/_authenticated'
@@ -760,6 +815,7 @@ export interface FileRouteTypes {
| '/(errors)/500'
| '/(errors)/503'
| '/_authenticated/'
| '/_authenticated/accounts/new'
| '/_authenticated/settings/access'
| '/_authenticated/settings/api-tokens'
| '/_authenticated/settings/appearance'
@@ -777,6 +833,7 @@ export interface FileRouteTypes {
| '/_authenticated/oauth2/'
| '/_authenticated/settings/'
| '/_authenticated/users/'
| '/_authenticated/accounts/$id/settings'
fileRoutesById: FileRoutesById
}
@@ -828,13 +885,15 @@ export const routeTree = rootRoute
"/_authenticated/settings",
"/_authenticated/users",
"/_authenticated/",
"/_authenticated/accounts/new",
"/_authenticated/attachment/",
"/_authenticated/import/",
"/_authenticated/search/",
"/_authenticated/accounts/",
"/_authenticated/api-docs/",
"/_authenticated/oauth2-result/",
"/_authenticated/oauth2/"
"/_authenticated/oauth2/",
"/_authenticated/accounts/$id/settings"
]
},
"/(auth)/500": {
@@ -884,6 +943,10 @@ export const routeTree = rootRoute
"filePath": "_authenticated/index.tsx",
"parent": "/_authenticated"
},
"/_authenticated/accounts/new": {
"filePath": "_authenticated/accounts/new.lazy.tsx",
"parent": "/_authenticated"
},
"/_authenticated/settings/access": {
"filePath": "_authenticated/settings/access.lazy.tsx",
"parent": "/_authenticated/settings"
@@ -951,6 +1014,10 @@ export const routeTree = rootRoute
"/_authenticated/users/": {
"filePath": "_authenticated/users/index.lazy.tsx",
"parent": "/_authenticated/users"
},
"/_authenticated/accounts/$id/settings": {
"filePath": "_authenticated/accounts/$id.settings.lazy.tsx",
"parent": "/_authenticated"
}
}
}
@@ -0,0 +1,29 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// 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 { createLazyFileRoute } from '@tanstack/react-router'
import { AccountSettingsPage } from '@/features/accounts/account-settings-page'
export const Route = createLazyFileRoute('/_authenticated/accounts/$id/settings')({
component: AccountSettingsWrapper,
})
function AccountSettingsWrapper() {
const { id } = Route.useParams()
return <AccountSettingsPage accountId={Number(id)} />
}
@@ -0,0 +1,24 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// 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 { createLazyFileRoute } from '@tanstack/react-router'
import { AccountNewPage } from '@/features/accounts/account-new'
export const Route = createLazyFileRoute('/_authenticated/accounts/new')({
component: AccountNewPage,
})