initial commit

This commit is contained in:
rustmailer
2025-11-19 02:14:37 +08:00
commit 1a8f95117e
355 changed files with 54089 additions and 0 deletions
@@ -0,0 +1,179 @@
//
// Copyright (c) 2025 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 { AccountModel } from '../data/schema'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
currentRow: AccountModel
}
export function AccountDetailDrawer({ open, onOpenChange, currentRow }: Props) {
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
>
<DialogContent className='max-w-5xl'>
<DialogHeader className='text-left mb-4'>
<DialogTitle>{currentRow.email}</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<ScrollArea className="h-[35rem] w-full pr-4 -mr-4 py-1">
<Tabs defaultValue="account" className="w-full">
<TabsList className="grid w-full grid-cols-1">
<TabsTrigger value="account">Account Details</TabsTrigger>
</TabsList>
<TabsContent value="account">
<div className="mt-4 space-y-6">
{/* Account Details Card */}
<Card>
<CardContent className="mt-4">
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">ID:</span>
<span>{currentRow.id}</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">Email:</span>
<span>{currentRow.email}</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">Name:</span>
<span>{currentRow.name ?? "n/a"}</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">Enabled:</span>
<Checkbox checked={currentRow.enabled} disabled />
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">Incremental Sync Interval:</span>
<span>every {currentRow.sync_interval_min} minutes</span>
</div>
<div className="flex flex-col gap-2">
<span className="text-muted-foreground">Capabilities:</span>
<code className="rounded-md bg-muted/50 px-2 py-1 text-sm border overflow-x-auto inline-block">
{currentRow.capabilities ? currentRow.capabilities.join(", ") : "n/a"}
</code>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">Date Selection:</span>
<span>
{currentRow.date_since?.fixed
? currentRow.date_since.fixed
: currentRow.date_since?.relative
? `recent ${currentRow.date_since.relative.value} ${currentRow.date_since.relative.unit}`
: "n/a"}
</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">Folder Limit:</span>
<span>{currentRow.folder_limit ? currentRow.folder_limit : "n/a"}</span>
</div>
</div>
</CardContent>
</Card>
{/* Server Configuration Card */}
<Card>
<CardHeader>
<CardTitle>Server Configuration (IMAP)</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">Host:</span>
<span>{currentRow.imap?.host}</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">Port:</span>
<span>{currentRow.imap?.port}</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">Encryption:</span>
<span>{currentRow.imap?.encryption}</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">Auth:</span>
{currentRow.imap?.auth.auth_type === "OAuth2" ? (
<Badge variant="outline" className="bg-blue-100 text-blue-800">
OAuth2
</Badge>
) : (
<Badge variant="outline" className="bg-blue-100 text-blue-800">
Password
</Badge>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">Use Proxy:</span>
<span>{currentRow.imap?.use_proxy ? "true" : "false"}</span>
</div>
</div>
</CardContent>
</Card>
{/* Sync Folders Card */}
<Card>
<CardHeader>
<CardTitle>Sync Folders</CardTitle>
</CardHeader>
<CardContent>
{currentRow.sync_folders?.length ? (
<div className="space-y-2">
<div className="text-sm mt-2 text-muted-foreground">
{currentRow.sync_folders.length} folder(s) configured for sync
</div>
<ScrollArea className="h-[300px] rounded-md border">
<div className="p-2">
{currentRow.sync_folders.map((folder, index) => (
<div
key={index}
className="flex items-center py-2 px-3 hover:bg-accent rounded-md transition-colors"
>
<span className="text-sm font-medium">{folder}</span>
</div>
))}
</div>
</ScrollArea>
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
No folders configured for sync
</div>
)}
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
</ScrollArea>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,499 @@
//
// Copyright (c) 2025 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 { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Form } from '@/components/ui/form';
import { AccountModel, ImapConfig } from '../data/schema';
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 CompleteStep from './complete-step';
import { create_account, autoconfig, update_account } from '@/api/account/api';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ToastAction } from '@/components/ui/toast';
import { AxiosError } from 'axios';
const encryptionSchema = z.union([
z.literal('Ssl'),
z.literal('StartTls'),
z.literal('None'),
]);
const authTypeSchema = z.union([
z.literal('Password'),
z.literal('OAuth2'),
]);
const authConfigSchema = (isEdit: boolean) =>
z.object({
auth_type: authTypeSchema,
password: z.string().optional(), // Always optional at base level
}).refine(
(data) => {
// Only validate password when:
// 1. Auth type is Password
// 2. In create mode (not edit)
if (data.auth_type === 'Password' && !isEdit) {
return !!data.password?.trim();
}
return true;
},
{
message: 'Password is required when auth method is Password',
path: ['password'],
}
);
const imapConfigSchema = (isEdit: boolean) =>
z.object({
host: z.string({ required_error: 'IMAP host is required' }).min(1, { message: 'IMAP host cannot be empty' }),
port: z.number().int().min(0, { message: 'IMAP port must be a positive integer' }).max(65535, { message: 'IMAP port must be less than 65536' }),
encryption: encryptionSchema,
auth: authConfigSchema(isEdit),
use_proxy: z.number().optional(),
});
const relativeDateSchema = z.object({
unit: z.enum(["Days", "Months", "Years"], { message: "Please select a unit" }),
value: z.number({ message: 'Please enter a value' }).int().min(1, "Must be at least 1"),
});
const dateSelectionSchema = z.union([
z.object({ fixed: z.string({ message: "Please select a date" }) },),
z.object({ relative: relativeDateSchema }),
z.undefined(),
]);
// Define static Account type to avoid z.infer issue with dynamic schema
export type Account = {
name?: string;
email: string;
imap: {
host: string;
port: number;
encryption: 'Ssl' | 'StartTls' | 'None';
auth: {
auth_type: 'Password' | 'OAuth2';
password?: string;
};
use_proxy?: number;
};
enabled: boolean;
date_since?: {
fixed?: string;
relative?: {
unit?: 'Days' | 'Months' | 'Years';
value?: number;
};
};
folder_limit?: number;
sync_interval_min: number;
};
const accountSchema = (isEdit: boolean) =>
z.object({
name: z.string().optional(),
email: z.string({ required_error: 'Email is required' }).email({ message: 'Invalid email address' }),
imap: imapConfigSchema(isEdit),
enabled: z.boolean(),
date_since: dateSelectionSchema.optional(),
folder_limit: z
.number({ invalid_type_error: 'Folder limit must be a number' })
.int()
.min(100, { message: 'Folder limit must be at least 100' })
.optional(),
sync_interval_min: z.number({ invalid_type_error: 'Incremental sync interval must be a number' }).int().min(10, { message: 'Incremental sync interval must be at least 10 minutes' }),
});
type Step = {
id: `step-${number}`;
name: string;
fields: (keyof Account)[];
};
export type Steps = [
{ id: "complete"; name: "Complete"; fields: [] },
...Step[]
];
const steps: Steps = [
{ id: "complete", name: "Complete", fields: [] },
{
id: "step-1",
name: "Email Address",
fields: ["email"],
},
{
id: "step-2",
name: "IMAP",
fields: ["imap"],
},
{ id: "step-3", name: "Sync Preferences", fields: ["enabled", "date_since", "folder_limit", "sync_interval_min"] },
{ id: "step-4", name: "Summary", fields: [] },
];
const LAST_STEP = steps.length - 1;
const COMPLETE_STEP = 0;
interface Props {
currentRow?: AccountModel;
open: boolean;
onOpenChange: (open: boolean) => void;
}
const defaultValues: Account = {
name: undefined,
email: '',
imap: {
host: "",
port: 993,
encryption: 'Ssl',
auth: {
auth_type: 'Password',
password: undefined,
},
use_proxy: undefined
},
enabled: true,
date_since: undefined,
folder_limit: undefined,
sync_interval_min: 10,
};
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) };
// Handle password and use_proxy conversion
imap.auth = { ...imap.auth, password: undefined };
if (imap.use_proxy === null) {
imap.use_proxy = undefined;
}
let account = {
name: currentRow.name === null ? undefined : currentRow.name,
email: currentRow.email,
imap,
enabled: currentRow.enabled,
date_since: currentRow.date_since ?? undefined,
folder_limit: currentRow.folder_limit ?? undefined,
sync_interval_min: currentRow.sync_interval_min ?? 10,
};
return account;
};
export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
const isEdit = !!currentRow;
const [currentStep, setCurrentStep] = React.useState(1);
const { toast } = useToast();
const [autoConfigLoading, setAutoConfigLoading] = React.useState(false);
const form = useForm<Account>({
mode: "all",
defaultValues: isEdit ? mapCurrentRowToFormValues(currentRow) : defaultValues,
resolver: zodResolver(accountSchema(isEdit)),
});
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: `Account ${isEdit ? 'Updated' : 'Created'}`,
description: `Your account has been successfully ${isEdit ? 'updated' : 'created'}.`,
action: <ToastAction altText="Close">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 ? 'Update' : 'Creation'} failed, please try again later`;
toast({
variant: "destructive",
title: `Account ${isEdit ? 'Update' : 'Creation'} Failed`,
description: errorMessage as string,
action: <ToastAction altText="Try again">Try again</ToastAction>,
});
console.error(error);
}
const onSubmit = React.useCallback(
(data: Account) => {
const commonData = {
email: data.email,
name: data.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,
date_since: data.date_since,
folder_limit: data.folder_limit,
sync_interval_min: data.sync_interval_min,
};
if (isEdit) {
updateMutation.mutate(commonData);
} else {
const payload = {
...commonData,
account_type: "IMAP",
};
createMutation.mutate(payload);
}
},
[isEdit, updateMutation, createMutation]
);
const handleNav = async (index: number) => {
let isValid = true;
let failedStep = currentStep;
for (let i = currentStep; i < index && 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].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');
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');
}
}
setAutoConfigLoading(false);
} catch (error) {
console.error('Auto-configuration failed:', error);
setAutoConfigLoading(false);
}
handleNav(currentStep + 1);
} else {
handleNav(currentStep + 1);
}
}
return (
<Dialog
open={open}
onOpenChange={(state) => {
form.reset();
setCurrentStep(1);
onOpenChange(state);
}}
>
<DialogContent className='max-w-5xl'>
<DialogHeader className='text-left mb-4'>
<DialogTitle>{isEdit ? "Update Account" : "Add Account"}</DialogTitle>
<DialogDescription>
{isEdit ? 'Update the email account here. ' : 'Add new email account here. '}
Click save when you're done.
</DialogDescription>
</DialogHeader>
<ScrollArea className="h-[38rem] w-full pr-4 -mr-4 py-1">
<>
{/* Mobile Steps (hidden on desktop) */}
{currentStep !== COMPLETE_STEP && (
<div className="flex my-5 space-x-4 md:hidden">
{steps.map(
(step, index) =>
index !== COMPLETE_STEP && (
<div className="z-20 my-3 ml-2 flex items-center" key={step.id}>
<Button
className={`size-9 rounded-full border font-bold ${`step-${currentStep}` === step.id ? "" : "bg-gray-200 text-black"
}`}
disabled={`step-${currentStep}` === step.id || currentStep === COMPLETE_STEP}
onClick={() => handleNav(index)}
>
{index}
</Button>
</div>
)
)}
</div>
)}
<div className="w-full max-w-full p-4">
<div className="flex md:h-min rounded-xl md:rounded-2xl p-4">
{currentStep !== COMPLETE_STEP && (
<div className="hidden md:block w-[260px] flex-shrink-0 rounded-xl p-5 pt-7 fixed">
{steps.map(
(step, index) =>
index !== COMPLETE_STEP && (
<div className="my-3 ml-2 flex items-center" key={step.id}>
<Button
className={`size-8 border rounded-full text-sm font-bold ${`step-${currentStep}` === step.id
? "bg-primary text-white"
: "bg-gray-200 text-black"
}`}
disabled={`step-${currentStep}` === step.id || currentStep === COMPLETE_STEP}
onClick={() => handleNav(index)}
>
{index}
</Button>
<div className="flex flex-col items-baseline uppercase ml-5">
<span className="text-xs">Step {index}</span>
<span className="font-bold text-sm tracking-wider">{step.name}</span>
</div>
</div>
)
)}
</div>
)}
<Form {...form}>
<form
id="account-register-form"
className={`flex-grow flex flex-col px-4 md:px-8 lg:px-12 ${currentStep !== COMPLETE_STEP ? 'ml-[240px]' : ''
}`}
onSubmit={form.handleSubmit(onSubmit)}
>
{currentStep === 1 && <Step1 isEdit={isEdit} />}
{currentStep === 2 && <Step2 isEdit={isEdit} />}
{currentStep === 3 && <Step3 />}
{currentStep === 4 && <Step4 />}
{currentStep === COMPLETE_STEP && <CompleteStep />}
</form>
</Form>
</div>
</div>
</>
</ScrollArea>
<DialogFooter className="flex flex-wrap gap-2">
<Button
disabled={currentStep === 1 || currentStep === COMPLETE_STEP}
type="button"
className="flex-grow sm:flex-grow-0 shadow-none text-nowrap text-sm disabled:invisible"
onClick={() => {
handleNav(currentStep - 1);
}}
>
Go Back
</Button>
<Button
disabled={currentStep === LAST_STEP || currentStep === COMPLETE_STEP}
type="button"
className="flex-grow sm:flex-grow-0 rounded-md md:rounded-lg px-6 disabled:hidden text-sm"
onClick={handleContinue}
>
{autoConfigLoading ? (
<>
<svg
className="animate-spin h-5 w-5 mr-3 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span>Auto-configuring...</span>
</>
) : (
"Continue"
)}
</Button>
<Button
disabled={currentStep !== LAST_STEP}
type="submit"
form="account-register-form"
className="flex-grow sm:flex-grow-0 rounded-md text-sm px-7 disabled:hidden md:rounded-lg"
>
{isEdit ? "Save changes" : "Submit"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,138 @@
//
// Copyright (c) 2025 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 { ColumnDef } from '@tanstack/react-table'
import LongText from '@/components/long-text'
import { AccountModel } from '../data/schema'
import { DataTableColumnHeader } from './data-table-column-header'
import { DataTableRowActions } from './data-table-row-actions'
import { format } from 'date-fns'
import { OAuth2Action } from './oauth2-action'
import { RunningStateCellAction } from './running-state-action'
import { EnableAction } from './enable-action'
export const columns: ColumnDef<AccountModel>[] = [
{
accessorKey: "id",
header: ({ column }) => (
<DataTableColumnHeader column={column} title='ID' />
),
cell: ({ row }) => {
return <LongText>{row.original.id}</LongText>
},
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "email",
header: ({ column }) => (
<DataTableColumnHeader column={column} title='Email' />
),
cell: ({ row }) => {
return <LongText>{row.original.email}</LongText>
},
enableHiding: false,
},
{
accessorKey: "enabled",
header: ({ column }) => (
<DataTableColumnHeader className="ml-4" column={column} title='Enabled' />
),
cell: EnableAction,
meta: { className: 'w-8 text-center' },
enableHiding: false,
},
{
id: 'auth_type',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='Auth' />
),
cell: OAuth2Action,
meta: { className: 'w-8' },
enableHiding: false,
enableSorting: false
},
{
id: 'account_type',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='Type' />
),
cell: ({ row }) => {
return <LongText>{row.original.account_type}</LongText>
},
meta: { className: 'w-8' },
enableHiding: false,
enableSorting: false
},
{
accessorKey: "sync_interval_sec",
header: ({ column }) => (
<DataTableColumnHeader column={column} title='Inc Sync' />
),
cell: ({ row }) => {
let account_type = row.original.account_type;
if (account_type === "NoSync") {
return <LongText className='max-w-12'>n/a</LongText>
}
return <LongText className='max-w-12'>{row.original.sync_interval_min} minutes</LongText>
},
meta: { className: 'w-12 text-center' },
enableHiding: false,
},
{
id: 'running_state',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='State' />
),
cell: RunningStateCellAction,
meta: { className: 'w-36' },
enableHiding: false,
},
{
accessorKey: 'created_at',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='Created At' />
),
cell: ({ row }) => {
const created_at = row.original.created_at;
const date = format(new Date(created_at), 'yyyy-MM-dd HH:mm:ss');
return <LongText className='max-w-36'>{date}</LongText>;
},
meta: { className: 'w-36' },
enableHiding: false,
},
{
accessorKey: 'updated_at',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='Updated At' />
),
cell: ({ row }) => {
const updated_at = row.original.updated_at;
const date = format(new Date(updated_at), 'yyyy-MM-dd HH:mm:ss');
return <LongText className='max-w-36'>{date}</LongText>;
},
meta: { className: 'w-36' },
enableHiding: false,
},
{
id: 'actions',
cell: DataTableRowActions,
},
]
@@ -0,0 +1,60 @@
//
// Copyright (c) 2025 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 { CheckCircle } from "lucide-react"; // Using Lucide icon library
import { Button } from "@/components/ui/button"; // Using custom button component
export default function CompleteStep() {
return (
<div className="flex flex-col items-center justify-center h-[25rem] p-6">
{/* Success Icon */}
<div className="mb-6 text-green-500">
<CheckCircle className="w-16 h-16" />
</div>
{/* Success Message */}
<h1 className="text-3xl font-bold mb-4">Registration Successful!</h1>
<p className="text-lg text-gray-600 mb-8 text-center">
Your email account has been successfully added.
</p>
{/* Action Buttons */}
<div className="mt-8 flex gap-4">
<Button
variant="default"
onClick={() => {
// Redirect to home page
window.location.href = "/";
}}
>
Authorize via OAuth2
</Button>
<Button
variant="outline"
onClick={() => {
// View details
window.location.href = "/accounts";
}}
>
View Details
</Button>
</div>
</div>
);
}
@@ -0,0 +1,89 @@
//
// Copyright (c) 2025 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 {
ArrowDownIcon,
ArrowUpIcon,
CaretSortIcon,
EyeNoneIcon,
} from '@radix-ui/react-icons'
import { Column } from '@tanstack/react-table'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
interface DataTableColumnHeaderProps<TData, TValue>
extends React.HTMLAttributes<HTMLDivElement> {
column: Column<TData, TValue>
title: string
}
export function DataTableColumnHeader<TData, TValue>({
column,
title,
className,
}: DataTableColumnHeaderProps<TData, TValue>) {
if (!column.getCanSort()) {
return <div className={cn(className)}>{title}</div>
}
return (
<div className={cn('flex items-center space-x-2', className)}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
size='sm'
className=' h-8 data-[state=open]:bg-accent'
>
<span>{title}</span>
{column.getIsSorted() === 'desc' ? (
<ArrowDownIcon className='ml-2 h-4 w-4' />
) : column.getIsSorted() === 'asc' ? (
<ArrowUpIcon className='ml-2 h-4 w-4' />
) : (
<CaretSortIcon className='ml-2 h-4 w-4' />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='start'>
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
<ArrowUpIcon className='mr-2 h-3.5 w-3.5 text-muted-foreground/70' />
Asc
</DropdownMenuItem>
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
<ArrowDownIcon className='mr-2 h-3.5 w-3.5 text-muted-foreground/70' />
Desc
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
<EyeNoneIcon className='mr-2 h-3.5 w-3.5 text-muted-foreground/70' />
Hide
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
@@ -0,0 +1,122 @@
//
// Copyright (c) 2025 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 {
ChevronLeftIcon,
ChevronRightIcon,
DoubleArrowLeftIcon,
DoubleArrowRightIcon,
} from '@radix-ui/react-icons'
import { Table } from '@tanstack/react-table'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
interface DataTablePaginationProps<TData> {
table: Table<TData>
showSelected?: boolean,
showPageSizeSelector?: boolean
}
export function DataTablePagination<TData>({
table,
showSelected = true,
showPageSizeSelector = true
}: DataTablePaginationProps<TData>) {
return (
<div className='flex items-center justify-between overflow-auto px-2'>
{showSelected && <div className='hidden flex-1 text-sm text-muted-foreground sm:block'>
{table.getFilteredSelectedRowModel().rows.length} of{' '}
{table.getFilteredRowModel().rows.length} row(s) selected.
</div>}
{!showPageSizeSelector && <div className='hidden flex-1 text-sm text-muted-foreground sm:block'>
10 rows per page.
</div>}
<div className='flex items-center sm:space-x-6 lg:space-x-8 ml-auto'>
{showPageSizeSelector && <div className='flex items-center space-x-2'>
<p className='hidden text-sm font-medium sm:block'>Rows per page</p>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value))
}}
>
<SelectTrigger className='h-8 w-[70px]'>
<SelectValue placeholder={table.getState().pagination.pageSize} />
</SelectTrigger>
<SelectContent side='top'>
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>}
<div className='flex w-[100px] items-center justify-center text-sm font-medium'>
Page {table.getState().pagination.pageIndex + 1} of{' '}
{table.getPageCount()}
</div>
<div className='flex items-center space-x-2'>
<Button
variant='outline'
className='hidden h-8 w-8 p-0 lg:flex'
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className='sr-only'>Go to first page</span>
<DoubleArrowLeftIcon className='h-4 w-4' />
</Button>
<Button
variant='outline'
className='h-8 w-8 p-0'
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className='sr-only'>Go to previous page</span>
<ChevronLeftIcon className='h-4 w-4' />
</Button>
<Button
variant='outline'
className='h-8 w-8 p-0'
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className='sr-only'>Go to next page</span>
<ChevronRightIcon className='h-4 w-4' />
</Button>
<Button
variant='outline'
className='hidden h-8 w-8 p-0 lg:flex'
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className='sr-only'>Go to last page</span>
<DoubleArrowRightIcon className='h-4 w-4' />
</Button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,113 @@
//
// Copyright (c) 2025 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 { DotsHorizontalIcon } from '@radix-ui/react-icons'
import { Row } from '@tanstack/react-table'
import { IconEdit, IconTrash } from '@tabler/icons-react'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useAccountContext } from '../context'
import { AccountModel } from '../data/schema'
import { Mailbox, MessageSquareMore } from 'lucide-react'
interface DataTableRowActionsProps {
row: Row<AccountModel>
}
export function DataTableRowActions({ row }: DataTableRowActionsProps) {
const { setOpen, setCurrentRow } = useAccountContext()
const account_type = row.original.account_type;
return (
<>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
className='flex h-8 w-8 p-0 data-[state=open]:bg-muted'
>
<DotsHorizontalIcon className='h-4 w-4' />
<span className='sr-only'>Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-[160px]'>
<DropdownMenuItem
onClick={() => {
setCurrentRow(row.original)
if (account_type === "IMAP") {
setOpen("edit-imap");
}
if (account_type === "NoSync") {
setOpen("edit-nosync");
}
}}
>
Edit
<DropdownMenuShortcut>
<IconEdit size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
{account_type === "IMAP" && <DropdownMenuItem
onClick={() => {
setCurrentRow(row.original)
setOpen('sync-folders')
}}
>
Sync Folders
<DropdownMenuShortcut>
<Mailbox size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>}
{account_type === "IMAP" && <DropdownMenuItem
onClick={() => {
setCurrentRow(row.original)
setOpen('detail')
}}
>
Detail
<DropdownMenuShortcut>
<MessageSquareMore size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
setCurrentRow(row.original)
setOpen('delete')
}}
className='!text-red-500'
>
Delete
<DropdownMenuShortcut>
<IconTrash size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)
}
@@ -0,0 +1,44 @@
//
// Copyright (c) 2025 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 { Table } from '@tanstack/react-table'
import { Input } from '@/components/ui/input'
interface DataTableToolbarProps<TData> {
table: Table<TData>
}
export function DataTableToolbar<TData>({
table,
}: DataTableToolbarProps<TData>) {
return (
<div className='flex items-center justify-between'>
<div className='flex flex-1 flex-col-reverse items-start gap-y-2 sm:flex-row sm:items-center sm:space-x-2'>
<Input
placeholder='Filter account...'
value={(table.getState().globalFilter as string) ?? ''}
onChange={(event) => {
table.setGlobalFilter(event.target.value);
}}
className='h-8 w-80'
/>
</div>
</div>
)
}
@@ -0,0 +1,136 @@
//
// Copyright (c) 2025 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 { IconAlertCircle, IconAlertTriangle } from '@tabler/icons-react'
import { toast } from '@/hooks/use-toast'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { AccountModel } from '../data/schema'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { ToastAction } from '@/components/ui/toast'
import { AxiosError } from 'axios'
import { remove_account } from '@/api/account/api'
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
currentRow: AccountModel
}
export function AccountDeleteDialog({ open, onOpenChange, currentRow }: Props) {
const [value, setValue] = useState('')
const queryClient = useQueryClient();
function handleSuccess() {
toast({
title: 'Account Deleted',
description: 'Your account has been successfully deleted.',
action: <ToastAction altText="Close">Close</ToastAction>,
});
queryClient.invalidateQueries({ queryKey: ['account-list'] });
onOpenChange(false);
}
function handleError(error: AxiosError) {
const errorMessage = error.response?.data ||
error.message ||
`Delete failed, please try again later`;
toast({
variant: "destructive",
title: `Account delete Failed`,
description: errorMessage as string,
action: <ToastAction altText="Try again">Try again</ToastAction>,
});
console.error(error);
}
const deleteMutation = useMutation({
mutationFn: (id: number) => remove_account(id),
onSuccess: handleSuccess,
onError: handleError
})
const handleDelete = () => {
if (value.trim() !== currentRow.email) return
deleteMutation.mutate(currentRow.id)
}
return (
<ConfirmDialog
open={open}
onOpenChange={onOpenChange}
handleConfirm={handleDelete}
disabled={value.trim() !== currentRow.email}
className="max-w-2xl"
title={
<span className='text-destructive'>
<IconAlertTriangle
className='mr-1 inline-block stroke-destructive'
size={18}
/>{' '}
Delete Account Permanently
</span>
}
desc={
<div className='space-y-4'>
<p className='mb-2'>
You are deleting <span className='font-bold'>{currentRow.email}</span>.
This will permanently remove:
</p>
<ul className="list-disc pl-6 space-y-1 text-sm text-muted-foreground">
<li>Account credentials and settings</li>
<li>Local cached metadata and sync status</li>
<li>IMAP synchronization data</li>
<li>OAuth tokens and API credentials</li>
</ul>
<div className="pt-2">
<Label>
Type the account email to confirm:
<Input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={`Type "${currentRow.email}" to confirm`}
className="mt-2"
/>
</Label>
</div>
<Alert variant='destructive'>
<IconAlertCircle className="h-4 w-4" />
<AlertTitle>This action cannot be undone!</AlertTitle>
<AlertDescription>
All related resources will be permanently erased.
</AlertDescription>
</Alert>
</div>
}
confirmText={
deleteMutation.isPending ? 'Deleting...' : 'Permanently Delete Account'
}
isLoading={deleteMutation.isPending}
destructive
/>
)
}
@@ -0,0 +1,92 @@
//
// Copyright (c) 2025 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 { Row } from '@tanstack/react-table'
import { AccountModel } from '../data/schema'
import { Switch } from '@/components/ui/switch'
import { useState } from 'react'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { ToastAction } from '@/components/ui/toast'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { update_account } from '@/api/account/api'
import { toast } from '@/hooks/use-toast'
import { AxiosError } from 'axios'
interface DataTableRowActionsProps {
row: Row<AccountModel>
}
export function EnableAction({ row }: DataTableRowActionsProps) {
const [open, setOpen] = useState(false);
const queryClient = useQueryClient();
const updateMutation = useMutation({
mutationFn: (enabled: boolean) =>
update_account(row.original.id, { enabled }),
onSuccess: () => {
setOpen(false);
toast({
title: 'Account Updated',
description: `Account has been successfully ${row.original.enabled ? 'disabled' : 'enabled'}.`,
action: <ToastAction altText="Close">Close</ToastAction>,
})
queryClient.invalidateQueries({ queryKey: ['account-list'] })
},
onError: (error: AxiosError) => {
const errorMessage =
(error.response?.data as { message?: string })?.message ||
error.message ||
'Status update failed, please try again later'
toast({
variant: "destructive",
title: 'Update Failed',
description: errorMessage,
action: <ToastAction altText="Try again">Try again</ToastAction>,
})
}
})
const handleConfirm = () => {
updateMutation.mutate(!row.original.enabled)
}
return (
<>
<Switch
checked={row.original.enabled}
onCheckedChange={() => setOpen(true)}
disabled={updateMutation.isPending}
/>
<ConfirmDialog
open={open}
onOpenChange={setOpen}
title={`${row.original.enabled ? 'Disable' : 'Enable'} Account`}
desc={
`Are you sure you want to ${row.original.enabled ? 'disable' : 'enable'} this account?` +
(row.original.enabled ? ' This will prevent the account from being used.' : '')
}
destructive={row.original.enabled}
confirmText={row.original.enabled ? 'Disable' : 'Enable'}
isLoading={updateMutation.isPending}
handleConfirm={handleConfirm}
/>
</>
)
}
@@ -0,0 +1,65 @@
//
// Copyright (c) 2025 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 { ProgressMap } from "@/api/account/api";
import { Progress } from "@/components/ui/progress";
interface Props {
progressMap: ProgressMap | undefined | null;
}
export function FolderSyncProgress({ progressMap }: Props) {
if (!progressMap || Object.keys(progressMap).length === 0) {
return <div className="text-muted-foreground text-sm">No Data</div>;
}
const folderNames = Object.keys(progressMap);
if (folderNames.length === 0) {
return <div className="text-muted-foreground text-sm">No folders to sync</div>;
}
return (
<div className="space-y-4">
{folderNames.map((folder) => {
const progress = progressMap[folder];
const percentage =
progress.total_batches > 0
? Math.min((progress.current_batch / progress.total_batches) * 100, 100)
: 0;
const isComplete = progress.current_batch >= progress.total_batches;
const textColor = isComplete ? "text-green-800" : "text-blue-800";
const progressColor = isComplete ? "bg-gray-200 [&>div]:bg-green-800 [&>div]:rounded-full h-1.5" : "bg-gray-200 [&>div]:bg-blue-800 [&>div]:rounded-full h-1.5";
return (
<div key={folder} className="space-y-1">
<div className={`flex justify-between text-sm ${textColor}`}>
<span className="truncate max-w-[70%]">{folder}</span>
<span className="text-sm font-medium">
{progress.current_batch}/{progress.total_batches} batches
</span>
</div>
<Progress value={percentage} className={progressColor} />
</div>
);
})}
</div>
);
}
@@ -0,0 +1,257 @@
//
// Copyright (c) 2025 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 { z } from 'zod';
import { Button } from '@/components/ui/button';
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 { useMutation, useQueryClient } from '@tanstack/react-query';
import { ToastAction } from '@/components/ui/toast';
import { AxiosError } from 'axios';
import React from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { create_account, update_account } from '@/api/account/api';
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { Loader2 } from 'lucide-react';
import { AccountModel } from '../data/schema';
const accountSchema = () =>
z.object({
name: z.string().optional(),
email: z.string({ required_error: 'Email is required' }).email({ message: 'Invalid email address' }),
enabled: z.boolean()
});
export type NoSyncAccount = {
name?: string;
email: string;
enabled: boolean;
};
interface Props {
currentRow?: AccountModel;
open: boolean;
onOpenChange: (open: boolean) => void;
}
const defaultValues: NoSyncAccount = {
name: '',
email: '',
enabled: true
};
const mapCurrentRowToFormValues = (currentRow: AccountModel): NoSyncAccount => {
let account = {
name: currentRow.name === null ? '' : currentRow.name,
email: currentRow.email,
enabled: currentRow.enabled
};
return account;
};
export function NoSyncAccountDialog({ currentRow, open, onOpenChange }: Props) {
const isEdit = !!currentRow;
const { toast } = useToast();
const form = useForm<NoSyncAccount>({
mode: "all",
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: `Account ${isEdit ? 'Updated' : 'Created'}`,
description: `Your account has been successfully ${isEdit ? 'updated' : 'created'}.`,
action: <ToastAction altText="Close">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 ? 'Update' : 'Creation'} failed, please try again later`;
toast({
variant: "destructive",
title: `Account ${isEdit ? 'Update' : 'Creation'} Failed`,
description: errorMessage as string,
action: <ToastAction altText="Try again">Try again</ToastAction>,
});
console.error(error);
}
const onSubmit = React.useCallback(
(data: NoSyncAccount) => {
const commonData = {
email: data.email,
name: data.name,
enabled: data.enabled
};
if (isEdit) {
updateMutation.mutate(commonData);
} else {
const payload = {
...commonData,
account_type: "NoSync"
};
createMutation.mutate(payload);
}
},
[isEdit, updateMutation, createMutation]
);
return (
<Dialog
open={open}
onOpenChange={(state) => {
form.reset();
onOpenChange(state);
}}
>
<DialogContent className='max-w-2xl'>
<DialogHeader className='text-left mb-4'>
<DialogTitle>{isEdit ? "Update Account" : "Add Account"}</DialogTitle>
<DialogDescription>
{isEdit ? 'Update the email account here. ' : 'Add new email account here. '}
Click save when you're done.
</DialogDescription>
</DialogHeader>
<ScrollArea className='h-[23rem] w-full pr-4 -mr-4 py-1'>
<Form {...form}>
<form
id='nosync-account-form'
onSubmit={form.handleSubmit(onSubmit)}
className='space-y-4 p-0.5'
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">
Email Address:
</FormLabel>
<FormControl>
<Input placeholder="e.g john.doe@gmail.com" {...field} />
</FormControl>
<FormMessage />
<FormDescription>
This account is used for identification purposes only and does not require syncing with an email server. It helps with importing email data.
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">
Name:
</FormLabel>
<FormControl>
<Input placeholder="e.g john.doe" {...field} />
</FormControl>
<FormDescription>Optional</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='enabled'
render={({ field }) => (
<FormItem className='flex flex-col items-start gap-y-1'>
<FormLabel>Enabled:</FormLabel>
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
Determines whether this account is active. If disabled, the account will not be able to import data or perform queries.
</FormDescription>
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<DialogFooter>
<Button
type='submit'
form='nosync-account-form'
disabled={isEdit ? updateMutation.isPending : createMutation.isPending}
>
{isEdit ? (
updateMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
"Save changes"
)
) : (
createMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating...
</>
) : (
"Create"
)
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,56 @@
//
// Copyright (c) 2025 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 { Row } from '@tanstack/react-table'
import { Button } from '@/components/ui/button'
import { useAccountContext } from '../context'
import { AccountModel } from '../data/schema'
interface DataTableRowActionsProps {
row: Row<AccountModel>
}
export function OAuth2Action({ row }: DataTableRowActionsProps) {
const { setOpen, setCurrentRow } = useAccountContext()
const mailer = row.original
const account_type = mailer.account_type;
if (account_type === "NoSync") {
return <span className="text-xs text-muted-foreground">n/a</span>
}
const isOAuth2 = mailer.imap?.auth.auth_type === "OAuth2"
if (isOAuth2) {
return (
<Button
variant="ghost"
size="sm"
className="text-xs text-blue-500 hover:text-blue-700 underline"
onClick={() => {
setCurrentRow(mailer)
setOpen("oauth2")
}}
>
OAuth2
</Button>
)
}
return <span className="text-xs text-muted-foreground">Password</span>
}
@@ -0,0 +1,179 @@
//
// Copyright (c) 2025 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 {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { AccountModel } from '../data/schema'
import { Button } from '@/components/ui/button'
import { get_oauth2_tokens } from '@/api/oauth2/api'
import { useQuery } from '@tanstack/react-query'
import { Card, CardContent } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { TableSkeleton } from '@/components/table-skeleton'
import { FileIcon } from 'lucide-react'
import { format, formatDistanceToNow } from 'date-fns'
import LongText from '@/components/long-text'
import { useCallback } from 'react'
import { IconCopy } from '@tabler/icons-react'
import { toast } from '@/hooks/use-toast'
import { ToastAction } from '@/components/ui/toast'
import { useNavigate } from '@tanstack/react-router'
interface Props {
currentRow: AccountModel
open: boolean
onOpenChange: (open: boolean) => void
}
export function OAuth2TokensDialog({ currentRow, open, onOpenChange }: Props) {
const navigate = useNavigate()
const { data: oauth2Tokens, isLoading } = useQuery({
queryKey: ['oauth2-tokens', currentRow.id],
queryFn: () => get_oauth2_tokens(currentRow.id),
enabled: open && !!currentRow.id,
retry: 0,
refetchOnWindowFocus: false,
refetchOnMount: false,
})
const onCopy = useCallback(async (access: boolean, token: string) => {
try {
await navigator.clipboard.writeText(token);
if (access) {
toast({
title: "Success",
description: "Access token copied to clipboard",
});
} else {
toast({
title: "Success",
description: "Refresh token copied to clipboard",
});
}
} catch (err) {
toast({
variant: "destructive",
title: "Failed to copy text",
description: (err as Error).message,
action: <ToastAction altText="Try again">Try again</ToastAction>,
});
}
}, []);
return (
<Dialog
open={open}
onOpenChange={(state) => {
onOpenChange(state)
}}
>
<DialogContent className='sm:max-w-3xl'>
<DialogHeader className='text-left'>
<DialogTitle>OAuth2 Tokens</DialogTitle>
<DialogDescription>
Details of the OAuth2 tokens for the account.
</DialogDescription>
</DialogHeader>
<Card>
<CardContent>
{isLoading ? (
<TableSkeleton columns={2} rows={10} />
) : oauth2Tokens ? (
<Table className='w-full'>
<TableHeader>
<TableRow>
<TableHead>Field</TableHead>
<TableHead>Value</TableHead>
<TableHead>Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className='max-w-80'>OAuth2 Name</TableCell>
<TableCell>
<LongText className='max-w-[240px] sm:max-w-[430px]'>{oauth2Tokens.oauth2_name}</LongText>
</TableCell>
</TableRow>
<TableRow>
<TableCell className='max-w-80'>Access Token</TableCell>
<TableCell>
<LongText className='max-w-[240px] sm:max-w-[430px]'>{oauth2Tokens.access_token}</LongText>
</TableCell>
<TableCell>
<Button className='text-xs px-1.5 py-0.5' onClick={() => onCopy(true, oauth2Tokens.access_token)}>
<IconCopy className="h-5 w-5" aria-hidden="true" />
</Button>
</TableCell>
</TableRow>
<TableRow>
<TableCell className='max-w-80'>Refresh Token</TableCell>
<TableCell>
<LongText className='max-w-[240px] sm:max-w-[430px]'>{oauth2Tokens.refresh_token}</LongText>
</TableCell>
<TableCell>
<Button className='text-xs px-1.5 py-0.5' onClick={() => onCopy(false, oauth2Tokens.refresh_token)}>
<IconCopy className="h-5 w-5" aria-hidden="true" />
</Button>
</TableCell>
</TableRow>
<TableRow>
<TableCell className='max-w-80'>Created At</TableCell>
<TableCell>
{format(new Date(oauth2Tokens.created_at), 'yyyy-MM-dd HH:mm:ss')}
</TableCell>
</TableRow>
<TableRow>
<TableCell className='max-w-80'>Updated At</TableCell>
<TableCell>
{formatDistanceToNow(new Date(oauth2Tokens.updated_at), { addSuffix: true })}
</TableCell>
</TableRow>
</TableBody>
</Table>
) : (
<div className="flex h-[250px] mt-4 shrink-0 items-center justify-center rounded-md border border-dashed">
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center">
<FileIcon className="h-10 w-10 text-muted-foreground" />
<h3 className="mt-4 text-lg font-semibold">No OAuth2 Tokens</h3>
<p className="mb-4 mt-2 text-sm text-muted-foreground">
The account has not completed the authorization process. Please
<a onClick={() => navigate({ to: '/oauth2' })} className="ml-1 text-blue-500 underline cursor-pointer">click here</a> to authorize the account.
</p>
</div>
</div>
)}
</CardContent>
</Card>
<DialogFooter>
<DialogClose asChild>
<Button variant='outline' className="px-2 py-1 text-sm h-auto">Close</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,45 @@
//
// Copyright (c) 2025 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 { Row } from '@tanstack/react-table'
import { Button } from '@/components/ui/button'
import { AccountModel } from '../data/schema';
import { useAccountContext } from '../context';
interface Props {
row: Row<AccountModel>
}
export function RunningStateCellAction({ row }: Props) {
const { setOpen, setCurrentRow } = useAccountContext()
let account_type = row.original.account_type;
if (account_type === "NoSync") {
return <span className="text-xs text-muted-foreground">n/a</span>
}
return (
<Button variant='ghost' className="h-auto p-1" onClick={() => {
setCurrentRow(row.original)
setOpen('running-state')
}}>
<span className="text-xs text-blue-500 cursor-pointer underline hover:text-blue-700">view details</span>
</Button>
)
}
@@ -0,0 +1,291 @@
//
// Copyright (c) 2025 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 {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { AccountModel } from '../data/schema'
import { useQuery } from '@tanstack/react-query'
import { account_state } from '@/api/account/api'
import { formatDistanceToNow, formatDuration, intervalToDuration } from 'date-fns'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Skeleton } from '@/components/ui/skeleton'
import { CheckCircle, Clock, Loader2, PlayCircle, FolderSync, FolderCheck } from 'lucide-react'
import { FolderSyncProgress } from './folder-sync-progress'
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
currentRow: AccountModel
}
export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
const { data: state, isLoading } = useQuery({
queryKey: ['running-state', currentRow.id],
queryFn: () => account_state(currentRow.id),
retry: 0,
refetchOnWindowFocus: false,
refetchInterval: 5000,
})
// Helper function to calculate duration
const calculateDuration = (start?: number, end?: number) => {
if (!start) {
return (
<span className="text-yellow-600 flex items-center gap-1">
<Clock className="w-4 h-4" /> Not Started
</span>
);
}
if (!end) {
return (
<span className="text-blue-600 flex items-center gap-1">
<PlayCircle className="w-4 h-4" /> In Progress
</span>
);
}
const duration = intervalToDuration({ start: new Date(start), end: new Date(end) })
return (
<span className="text-green-600 flex items-center gap-1">
<CheckCircle className="w-4 h-4" /> {formatDuration(duration, { format: ['hours', 'minutes', 'seconds'] })}
</span>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full max-w-7xl sm:rounded-xl p-0 overflow-hidden">
<DialogHeader className="text-left space-y-2 px-4 sm:px-6 pt-4 sm:pt-6">
<DialogTitle className="flex flex-wrap items-center gap-2 text-base sm:text-lg">
<span className="text-blue-500 font-medium truncate">{currentRow.email}</span>
</DialogTitle>
</DialogHeader>
<ScrollArea className="max-h-[85vh] px-4 sm:px-6 pb-6">
{isLoading && (
<div className="space-y-4 py-6">
<Skeleton className="h-6 w-1/2" />
<Skeleton className="h-6 w-1/3" />
<div className="flex justify-center items-center py-4">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
</div>
)}
{!isLoading && state && (
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4 sm:gap-6 mt-4">
<div className="xl:col-span-2 space-y-1 sm:space-y-1">
{/* Initial Sync */}
<div className="p-4 border rounded-lg bg-card">
<div className="flex flex-wrap items-center justify-between mb-3 gap-2">
<h3 className="text-base sm:text-lg font-semibold flex items-center gap-2">
{state.is_initial_sync_completed ? (
<FolderCheck className="w-5 h-5 text-green-500" />
) : (
<FolderSync className="w-5 h-5 text-blue-500" />
)}
Initial Sync
</h3>
{state.is_initial_sync_completed ? (
<span className="text-xs bg-green-100 text-green-800 px-2 py-1 rounded-full">
Completed
</span>
) : (
<span className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded-full">
In Progress
</span>
)}
</div>
<div className="grid grid-cols-3 gap-6 w-full">
{/* Start Time */}
<div className="flex flex-col text-sm">
<span className="text-muted-foreground">Start Time:</span>
<span className="font-medium">
{state.initial_sync_start_time ? (
<span className="text-green-600">
{formatDistanceToNow(new Date(state.initial_sync_start_time), { addSuffix: true })}
</span>
) : (
<span className="flex items-center gap-1 text-yellow-600">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-yellow-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-yellow-500"></span>
</span>
Not Started
</span>
)}
</span>
</div>
<div className="flex flex-col text-sm">
<span className="text-muted-foreground">End Time:</span>
<span className="font-medium">
{state.initial_sync_end_time ? (
<span className="text-green-600">
{formatDistanceToNow(new Date(state.initial_sync_end_time), { addSuffix: true })}
</span>
) : state.initial_sync_start_time ? (
<span className="flex items-center gap-1 text-blue-600">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500"></span>
</span>
In Progress
</span>
) : (
<span className="text-yellow-600">Not Started</span>
)}
</span>
</div>
{/* Duration */}
<div className="flex flex-col text-sm">
<span className="text-muted-foreground">Duration:</span>
<span className="font-medium">
{(() => {
if (!state.initial_sync_start_time)
return <span className="text-yellow-600">Not Started</span>;
if (!state.initial_sync_end_time)
return <span className="text-blue-600 animate-pulse">Calculating...</span>;
const diff = new Date(state.initial_sync_end_time).getTime() - new Date(state.initial_sync_start_time).getTime();
const h = Math.floor(diff / 3600000);
const m = Math.floor((diff % 3600000) / 60000);
const s = Math.floor((diff % 60000) / 1000);
return <span className="text-foreground">{h}h {m}m {s}s</span>;
})()}
</span>
</div>
</div>
</div>
<div className="p-4 border rounded-lg bg-card">
{state && (
<div>
<h3 className="text-base sm:text-lg font-semibold mb-3">Sync Progres</h3>
<ScrollArea className="h-70 sm:h-70 border rounded-md p-2">
<div className='space-y-4'>
<FolderSyncProgress progressMap={state.progress} />
</div>
</ScrollArea>
</div>
)}
</div>
{/* Incremental Sync */}
<div className="p-4 border rounded-lg bg-card">
<h3 className="text-base sm:text-lg font-semibold mb-3">Incremental Sync</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Start Time:</span>
<span className="font-medium">
{state.last_incremental_sync_start ? (
formatDistanceToNow(new Date(state.last_incremental_sync_start), { addSuffix: true })
) : (
<span className="text-yellow-600">Not Started</span>
)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">End Time:</span>
<span className="font-medium">
{state.last_incremental_sync_end ? (
formatDistanceToNow(new Date(state.last_incremental_sync_end), { addSuffix: true })
) : state.last_incremental_sync_start ? (
<span className="text-blue-600">In Progress</span>
) : (
<span className="text-yellow-600">Not Started</span>
)}
</span>
</div>
</div>
<div className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Duration:</span>
<span className="font-medium">
{calculateDuration(state.last_incremental_sync_start, state.last_incremental_sync_end)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Sync Interval:</span>
<span className="font-medium">
Every {currentRow.sync_interval_min} minutes
</span>
</div>
</div>
</div>
</div>
</div>
<div className="p-4 border rounded-lg bg-card">
<h3 className="text-base sm:text-lg font-semibold mb-3">Error Logs</h3>
<ScrollArea className="h-[20rem] sm:h-[32rem]">
<div className="space-y-3">
{state.errors.length ? (
state.errors
.sort((a, b) => b.at - a.at)
.map((item, index) => (
<div
key={index}
className="flex flex-col items-start gap-2 rounded-lg border p-3 text-left text-xs sm:text-sm transition-all hover:bg-accent"
>
<div className="flex w-full flex-col gap-1">
<div className="text-xs font-medium text-muted-foreground">
{formatDistanceToNow(new Date(item.at), { addSuffix: true })}
</div>
<div className="font-medium break-words">{item.error}</div>
</div>
</div>
))
) : (
<div className="h-full flex justify-center items-center py-8">
<p className="text-sm text-muted-foreground">No error logs available.</p>
</div>
)}
</div>
</ScrollArea>
</div>
</div>
)}
{!isLoading && !state && (
<div className="h-full flex flex-col justify-center items-center py-8 text-center space-y-2">
<p className="text-sm text-muted-foreground">No active state available</p>
<p className="text-xs text-muted-foreground">
Account synchronization may not have started yet
</p>
</div>
)}
</ScrollArea>
<DialogFooter className="pt-4 px-4 sm:px-6 pb-4 border-t">
<DialogClose asChild>
<Button variant="outline" className="w-full sm:w-auto">
Close
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,86 @@
//
// Copyright (c) 2025 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";
interface StepProps {
isEdit: boolean;
}
export default function Step1({ isEdit }: StepProps) {
const { control } = useFormContext<Account>();
return (
<>
<h1 className="my-3 md:mt-8">Email Account Registration</h1>
<p className="mb-5 md:mb-8">
Please provide your email address. In the next steps, you will configure the IMAP/SMTP details. Using this email address, we will attempt to automatically retrieve the SMTP/IMAP server addresses.
</p>
<div className="space-y-8">
<FormField
control={control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">
Email Address:
</FormLabel>
<FormControl>
<Input placeholder="e.g john.doe@example.com" readOnly={isEdit} {...field} />
</FormControl>
<FormMessage />
{isEdit && (
<FormDescription>
The email account address cannot be modified when editing.
</FormDescription>
)}
</FormItem>
)}
/>
<FormField
control={control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">
Name:
</FormLabel>
<FormControl>
<Input placeholder="e.g john.doe" {...field} />
</FormControl>
<FormDescription>Optional</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</>
);
}
@@ -0,0 +1,196 @@
//
// Copyright (c) 2025 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";
interface StepProps {
isEdit: boolean;
}
export default function Step2({ isEdit }: StepProps) {
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">
IMAP Host:
</FormLabel>
<FormControl>
<Input placeholder="e.g imap.example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="imap.port"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">
IMAP Port:
</FormLabel>
<FormControl>
<Input type="number" placeholder="e.g 993" {...field} onChange={(e) => field.onChange(parseInt(e.target.value, 10))} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="imap.encryption"
render={({ field }) => (
<FormItem>
<FormLabel>IMAP Auth Method:</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select an authentication method" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="Ssl">Ssl</SelectItem>
<SelectItem value="StartTls">StartTls</SelectItem>
<SelectItem value="None">None</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Choose the authentication method for IMAP.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="imap.auth.auth_type"
render={({ field }) => (
<FormItem>
<FormLabel>IMAP Auth Method:</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select an authentication method" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="OAuth2">OAuth2</SelectItem>
<SelectItem value="Password">Password</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Choose the authentication method for IMAP.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{imapAuthMethod === "Password" && (
<FormField
control={control}
name="imap.auth.password"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">
IMAP Password:
</FormLabel>
<FormControl>
<PasswordInput placeholder={isEdit ? "Leave empty to keep current password" : "Enter your password"} {...field} />
</FormControl>
<FormMessage />
{isEdit && (
<FormDescription>
Leave empty to keep the existing password, or enter a new password to update it.
</FormDescription>
)}
</FormItem>
)}
/>
)}
<FormField
control={control}
name='imap.use_proxy'
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">Use Proxy(optional):</FormLabel>
<FormControl>
<Select
onValueChange={(val) => field.onChange(Number(val))}
defaultValue={field.value?.toString()}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a proxy" />
</SelectTrigger>
</FormControl>
<SelectContent>
{proxyOptions && proxyOptions.length > 0 ? (
proxyOptions.map((option) => (
<SelectItem key={option.value} value={option.value.toString()}>
{option.label}
</SelectItem>
))
) : (
<SelectItem disabled value="__none__">No proxy available</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>
<FormDescription className='flex-1'>
Use a SOCKS5 proxy for IMAP connections.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</>
);
}
@@ -0,0 +1,239 @@
//
// Copyright (c) 2025 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 } from "@/lib/utils";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { useState } from "react";
import { Checkbox } from "@/components/ui/checkbox";
export default function Step3() {
const { control, getValues, setValue } = useFormContext<Account>();
const current = getValues();
const [rangeType, setRangeType] = useState<'none' | 'fixed' | 'relative'>(current.date_since ? (current.date_since.fixed ? 'fixed' : 'relative') : 'none')
return (
<>
<div className="space-y-8">
<FormField
control={control}
name="sync_interval_min"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">
Incremental Sync(minutes):
</FormLabel>
<FormControl>
<Input type="number" placeholder="e.g 300" {...field} onChange={(e) => field.onChange(parseInt(e.target.value, 10))} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name='enabled'
render={({ field }) => (
<FormItem className='flex flex-col items-start gap-y-1'>
<FormLabel>Enabled:</FormLabel>
<FormControl>
<Checkbox
className='mt-2'
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
Determines whether this account is active. If disabled, related syncs will not run.
</FormDescription>
</FormItem>
)}
/>
<FormLabel className="flex items-center justify-between">
Date Since:
</FormLabel>
<RadioGroup
defaultValue={rangeType}
onValueChange={(value: 'fixed' | 'relative' | 'none') => {
setRangeType(value);
if (value === 'none') {
setValue("date_since", undefined, { shouldValidate: true });
}
if (value === 'fixed') {
setValue("date_since", { fixed: undefined }, { shouldValidate: true });
}
if (value === 'relative') {
setValue("date_since", { relative: { value: undefined, unit: undefined } }, { shouldValidate: true });
}
}}
className='flex flex-row space-x-4'
>
<FormItem className='flex items-center space-x-3'>
<RadioGroupItem value='none' />
<FormLabel className='font-normal'>None</FormLabel>
</FormItem>
<FormItem className='flex items-center space-x-3'>
<RadioGroupItem value='fixed' />
<FormLabel className='font-normal'>Fixed</FormLabel>
</FormItem>
<FormItem className='flex items-center space-x-3'>
<RadioGroupItem value='relative' />
<FormLabel className='font-normal'>Relative</FormLabel>
</FormItem>
</RadioGroup>
<FormDescription>defines the sync start dateeither specific or relative to now. Preceding emails are excluded,{rangeType === 'fixed' ? " syncs data after a set date" : " shifts the sync date over time, syncing only recent data."}</FormDescription>
{rangeType === 'fixed' && <FormField
control={control}
name="date_since.fixed"
render={({ field }) => (
<FormItem className="flex flex-col">
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant={"outline"}
className={cn(
"w-[240px] pl-3 text-left font-normal text-sm text-brand-marine-blue",
!field.value && "text-muted-foreground"
)}
>
{field.value ? (
format(field.value, "PPP")
) : (
<span>Pick a date</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(new Date(field.value).setHours(0, 0, 0, 0)) : undefined}
onSelect={(value) => {
if (value) {
const formattedDate = value.toLocaleDateString('en-CA')
field.onChange(formattedDate)
} else {
field.onChange(null)
}
}}
disabled={(date) =>
date > new Date() || date < new Date("1900-01-01")
}
initialFocus
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>}
{rangeType === 'relative' && <div className="flex flex-row gap-4">
<div className="flex-1">
<FormField
control={control}
name="date_since.relative.value"
render={({ field }) => (
<FormItem>
<FormControl>
<Input type="number" placeholder="e.g 1" {...field} onChange={(e) => field.onChange(parseInt(e.target.value, 10))} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="w-1/2">
<FormField
control={control}
name="date_since.relative.unit"
render={({ field }) => (
<FormItem>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select unit" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="Days">Days</SelectItem>
<SelectItem value="Months">Months</SelectItem>
<SelectItem value="Years">Years</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>}
<FormField
control={control}
name="folder_limit"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">
Folder Sync Limit:
</FormLabel>
<FormDescription>
Limit the number of emails to sync per folder (minimum 100). Leave empty for no limit.
</FormDescription>
<FormControl>
<Input
type="number"
placeholder="e.g. 1000"
{...field}
onChange={(e) =>
field.onChange(e.target.value ? parseInt(e.target.value, 10) : undefined)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</>
);
}
@@ -0,0 +1,121 @@
//
// Copyright (c) 2025 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";
export default function Step4() {
const { getValues } = useFormContext<Account>();
const summaryData = getValues();
return (
<>
<div className="p-5 rounded-xl">
<Accordion type="multiple" defaultValue={['email', 'name', 'minimal_sync', 'isolated_index', 'imap', 'smtp', 'date_since', 'folder_limit', 'sync_folders', 'language', 'sync_interval']}>
<AccordionItem key="email" value="email">
<AccordionTrigger className="font-medium capitalize text-gray-600">
Email:
</AccordionTrigger>
<AccordionContent>
{summaryData.email}
</AccordionContent>
</AccordionItem>
<AccordionItem key="name" value="name">
<AccordionTrigger className="font-medium capitalize text-gray-600">
Name:
</AccordionTrigger>
<AccordionContent>
{summaryData.name ?? "n/a"}
</AccordionContent>
</AccordionItem>
<AccordionItem key="imap" value='imap'>
<AccordionTrigger className="font-medium capitalize text-gray-600">
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">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">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">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">auth_type:</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">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">use proxy:</td>
<td className="px-6 py-2 whitespace-nowrap text-sm">{summaryData.imap.use_proxy ? "true" : "false"}</td>
</tr>
</tbody>
</table>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem key="date_since" value='date_since'>
<AccordionTrigger className="font-medium capitalize text-gray-600">
Date Selection:
</AccordionTrigger>
<AccordionContent>
{summaryData.date_since?.fixed ?
'since ' + summaryData.date_since.fixed
: summaryData.date_since?.relative && summaryData.date_since.relative.value && summaryData.date_since.relative.unit ?
'recent ' + summaryData.date_since.relative.value + ' ' + summaryData.date_since.relative.unit
: 'n/a'}
</AccordionContent>
</AccordionItem>
<AccordionItem key="folder_limit" value='folder_limit'>
<AccordionTrigger className="font-medium capitalize text-gray-600">
Folder Sync Limit:
</AccordionTrigger>
<AccordionContent>
{summaryData.folder_limit ? summaryData.folder_limit : 'n/a'}
</AccordionContent>
</AccordionItem>
<AccordionItem key="sync_interval" value='sync_interval'>
<AccordionTrigger className="font-medium capitalize text-gray-600">
Incremental Sync Interval:
</AccordionTrigger>
<AccordionContent>
{summaryData.sync_interval_min} minutes
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</>
);
}
@@ -0,0 +1,183 @@
//
// Copyright (c) 2025 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 {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Loader2 } from 'lucide-react'
import { useCallback, useMemo, useState } from 'react'
import { AccountModel } from '../data/schema'
import { toast } from '@/hooks/use-toast'
import { list_mailboxes } from '@/api/mailbox/api'
import { buildTree } from '@/lib/build-tree'
import { TreeDataItem, TreeView } from '@/components/tree-view'
import { Skeleton } from '@/components/ui/skeleton'
import { update_account } from '@/api/account/api'
import { ToastAction } from '@/components/ui/toast'
import { AxiosError } from 'axios'
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
currentRow: AccountModel
}
export function SyncFoldersDialog({ currentRow, open, onOpenChange }: Props) {
const [selectedFolders, setSelectedFolders] = useState<string[]>(currentRow.sync_folders || []);
const [isSubmitting, setIsSubmitting] = useState(false);
const queryClient = useQueryClient();
const { data: mailboxes, isLoading } = useQuery({
queryKey: ['account-mailboxes', currentRow.id],
queryFn: () => list_mailboxes(currentRow.id, true),
enabled: open,
});
// Convert mailbox names to IDs for initial selection
const initialSelectedItemIds = useMemo(() => {
if (!mailboxes) return [];
return mailboxes
.filter(mailbox => selectedFolders.includes(mailbox.name))
.map(mailbox => mailbox.id.toString());
}, [mailboxes, selectedFolders]);
// Convert data to tree structure
const treeData = useMemo(() => {
if (!mailboxes) return [];
return buildTree(mailboxes, undefined, true, true);
}, [mailboxes]);
const handleSelectItems = useCallback((selectedItems: TreeDataItem[]) => {
const selected = selectedItems
.map(item => mailboxes?.find(m => m.id === parseInt(item.id, 10))?.name)
.filter(Boolean) as string[];
setSelectedFolders(selected);
}, [mailboxes]);
const updateMutation = useMutation({
mutationFn: (data: Record<string, any>) => update_account(currentRow?.id ?? '', data),
onSuccess: handleSuccess,
onError: handleError
})
function handleSuccess() {
toast({
title: 'Account Sync Folders Updated',
description: 'Account has been successfully updated.',
action: <ToastAction altText="Close">Close</ToastAction>,
});
queryClient.invalidateQueries({ queryKey: ['account-list'] });
setIsSubmitting(false);
onOpenChange(false);
}
function handleError(error: AxiosError) {
const errorMessage = (error.response?.data as { message?: string })?.message ||
error.message ||
'Update failed, please try again later';
toast({
variant: "destructive",
title: 'Account Sync Folders Update Failed',
description: errorMessage as string,
action: <ToastAction altText="Try again">Try again</ToastAction>,
});
setIsSubmitting(false);
console.error(error);
}
const handleSubmit = async () => {
if (selectedFolders.length === 0) {
toast({
title: 'Error',
description: 'Please select at least one folder',
variant: 'destructive',
});
return;
}
setIsSubmitting(true);
updateMutation.mutate({
sync_folders: selectedFolders,
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Select Sync Folders</DialogTitle>
<DialogDescription>
Choose folders to sync for {currentRow.email}, Newly added folders will begin downloading during the next sync cycle.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex items-center justify-end">
<div className="text-sm text-muted-foreground">
{selectedFolders.length} folder(s) selected
</div>
</div>
{isLoading && (
<div className="space-y-2">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</div>
)}
{!isLoading && (
<TreeView
data={treeData}
multiple
expandAll
clickRowToSelect={false}
initialSelectedItemIds={initialSelectedItemIds}
onSelectItemsChange={handleSelectItems}
/>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={isSubmitting || isLoading}
>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save Changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,153 @@
//
// Copyright (c) 2025 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 {
ColumnDef,
ColumnFiltersState,
RowData,
SortingState,
VisibilityState,
flexRender,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { AccountModel } from '../data/schema'
import { DataTablePagination } from './data-table-pagination'
import { DataTableToolbar } from './data-table-toolbar'
declare module '@tanstack/react-table' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface ColumnMeta<TData extends RowData, TValue> {
className: string
}
}
interface DataTableProps {
columns: ColumnDef<AccountModel>[]
data: AccountModel[]
}
export function AccountTable({ columns, data }: DataTableProps) {
const [rowSelection, setRowSelection] = useState({})
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [sorting, setSorting] = useState<SortingState>([])
const table = useReactTable({
data,
columns,
state: {
sorting,
columnVisibility,
rowSelection,
columnFilters,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
})
return (
<div className='space-y-4'>
<DataTableToolbar table={table} />
<div className='rounded-md border'>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className='group/row'>
{headerGroup.headers.map((header) => {
return (
<TableHead
key={header.id}
colSpan={header.colSpan}
className={header.column.columnDef.meta?.className ?? ''}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
className='group/row'
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={cell.column.columnDef.meta?.className ?? ''}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className='h-24 text-center'
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<DataTablePagination table={table} />
</div>
)
}
@@ -0,0 +1,53 @@
//
// Copyright (c) 2025 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 React from 'react'
import { AccountModel } from '../data/schema'
export type AccountDialogType = 'add-imap' | 'add-nosync' | 'edit-imap' | 'edit-nosync' | 'delete' | 'detail' | 'oauth2' | 'running-state' | 'sync-folders'
interface AccountContextType {
open: AccountDialogType | null
setOpen: (str: AccountDialogType | null) => void
currentRow: AccountModel | null
setCurrentRow: React.Dispatch<React.SetStateAction<AccountModel | null>>
}
const AccountContext = React.createContext<AccountContextType | null>(null)
interface Props {
children: React.ReactNode
value: AccountContextType
}
export default function AccountProvider({ children, value }: Props) {
return <AccountContext.Provider value={value}>{children}</AccountContext.Provider>
}
export const useAccountContext = () => {
const accountContext = React.useContext(AccountContext)
if (!accountContext) {
throw new Error(
'useAccountContext has to be used within <AccountContext.Provider>'
)
}
return accountContext
}
+63
View File
@@ -0,0 +1,63 @@
//
// Copyright (c) 2025 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/>.
type Encryption = 'Ssl' | 'StartTls' | 'None';
type AuthType = 'Password' | 'OAuth2';
type Unit = 'Days' | 'Months' | 'Years';
type AccountType = 'IMAP' | 'NoSync';
// Interface definitions
interface AuthConfig {
auth_type: AuthType;
password?: string;
}
export interface ImapConfig {
host: string;
port: number; // integer, 0-65535
encryption: Encryption;
auth: AuthConfig;
use_proxy?: number;
}
interface RelativeDate {
unit: Unit;
value: number; // integer, minimum 1
}
interface DateSelection {
fixed?: string; // format: "YYYY-MM-DD"
relative?: RelativeDate;
}
export interface AccountModel {
id: number;
account_type: AccountType;
imap?: ImapConfig;
enabled: boolean;
name?: string,
email: string;
capabilities?: string[];
date_since?: DateSelection;
folder_limit?: number,
sync_folders: string[];
sync_interval_min?: number;
created_at: number;
updated_at: number;
use_proxy?: number
}
+216
View File
@@ -0,0 +1,216 @@
//
// Copyright (c) 2025 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 useDialogState from '@/hooks/use-dialog-state'
import { Button } from '@/components/ui/button'
import { Main } from '@/components/layout/main'
import { AccountActionDialog } from './components/action-dialog'
import { columns } from './components/columns'
import { AccountDeleteDialog } from './components/delete-dialog'
import { AccountTable } from './components/table'
import AccountProvider, {
type AccountDialogType,
} from './context'
import { MoreVertical, Plus } from 'lucide-react'
import Logo from '@/assets/logo.svg'
import { AccountModel } from './data/schema'
import { AccountDetailDrawer } from './components/account-detail'
import { list_accounts } from '@/api/account/api'
import { TableSkeleton } from '@/components/table-skeleton'
import { useQuery } from '@tanstack/react-query'
import { OAuth2TokensDialog } from './components/oauth2-tokens'
import { RunningStateDialog } from './components/running-state-dialog'
import { FixedHeader } from '@/components/layout/fixed-header'
import { SyncFoldersDialog } from './components/sync-folders'
import { NoSyncAccountDialog } from './components/nosync-dialog'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
export default function Accounts() {
// Dialog states
const [currentRow, setCurrentRow] = useState<AccountModel | null>(null)
const [open, setOpen] = useDialogState<AccountDialogType>(null)
const { data: accountList, isLoading } = useQuery({
queryKey: ['account-list'],
queryFn: list_accounts,
})
const hasAccounts = accountList != null && accountList.items.length > 0;
return (
<AccountProvider value={{ open, setOpen, currentRow, setCurrentRow }}>
<FixedHeader />
<Main>
<div className="mx-auto w-full max-w-7xl px-4">
{/* Header Section */}
<div className='mb-2 flex items-center justify-between flex-wrap gap-x-4 gap-y-2'>
<div>
<h2 className='text-2xl font-bold tracking-tight'>Email Accounts</h2>
<p className='text-muted-foreground'>
Manage and configure your email accounts.
</p>
</div>
<div className="flex gap-2">
<div className="flex rounded-md shadow-sm">
<Button
onClick={() => setOpen("add-imap")}
className="rounded-r-none border-r-0"
>
<Plus className="h-4 w-4" />
Add IMAP
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
className="h-9 w-9 rounded-l-none border-l-0"
>
<MoreVertical className="h-4 w-4" />
<span className="sr-only">More account types</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setOpen("add-nosync")}>
<Plus className="h-4 w-4" />
Add NoSync
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
{/* Table / Empty State Section */}
<div className='flex-1 overflow-auto py-1 flex-row lg:space-x-12 space-y-0'>
{isLoading ? (
<TableSkeleton columns={columns.length} rows={10} />
) : hasAccounts ? (
<AccountTable data={accountList.items} columns={columns} />
) : (
<div className="flex h-[450px] shrink-0 items-center justify-center rounded-md border border-dashed">
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center">
<img
src={Logo}
className="max-h-[100px] w-auto opacity-20 saturate-0 transition-all duration-300 hover:opacity-100 hover:saturate-100 object-contain"
alt="RustMailer Logo"
/>
<h3 className="mt-4 text-lg font-semibold">No Account Configurations</h3>
<p className="mb-4 mt-2 text-sm text-muted-foreground">
You haven't added any Account configurations yet. Add one to start using Account features.
</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-imap")}>
Add Configuration
</Button>
</div>
</div>
</div>
)}
</div>
</div>
</Main>
<AccountActionDialog
key='imap-account-add'
open={open === 'add-imap'}
onOpenChange={() => setOpen('add-imap')}
/>
<NoSyncAccountDialog
key='nosync-account-add'
open={open === 'add-nosync'}
onOpenChange={() => setOpen('add-nosync')}
/>
{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'}
onOpenChange={() => {
setOpen('edit-nosync')
setTimeout(() => {
setCurrentRow(null)
}, 500)
}}
currentRow={currentRow}
/>
<RunningStateDialog
key='running-state'
open={open === 'running-state'}
onOpenChange={() => {
setOpen('running-state')
setTimeout(() => {
setCurrentRow(null)
}, 500)
}}
currentRow={currentRow}
/>
<AccountDeleteDialog
key={`account-delete-${currentRow.id}`}
open={open === 'delete'}
onOpenChange={() => {
setOpen('delete')
setTimeout(() => {
setCurrentRow(null)
}, 500)
}}
currentRow={currentRow}
/>
<SyncFoldersDialog
key={`sync-folders-${currentRow.id}`}
open={open === 'sync-folders'}
onOpenChange={() => {
setOpen('sync-folders')
setTimeout(() => {
setCurrentRow(null)
}, 500)
}}
currentRow={currentRow}
/>
<AccountDetailDrawer
open={open === 'detail'}
onOpenChange={() => setOpen('detail')}
currentRow={currentRow}
/>
<OAuth2TokensDialog open={open === 'oauth2'}
onOpenChange={() => setOpen('oauth2')}
currentRow={currentRow}
/>
</>
)}
</AccountProvider>
)
}