feat: Display Name / Alias for IMAP Accounts #306

This commit is contained in:
rustmailer
2026-06-25 02:00:20 +08:00
parent af089958e9
commit 8542ba0d28
12 changed files with 88 additions and 312 deletions
+9 -15
View File
@@ -87,14 +87,10 @@ impl FilterRule {
}
fn matches_exact(&self, value: &str) -> bool {
if !self.include.is_empty()
&& !self.include.iter().any(|e| e.eq_ignore_ascii_case(value))
{
if !self.include.is_empty() && !self.include.iter().any(|e| e.eq_ignore_ascii_case(value)) {
return false;
}
if !self.exclude.is_empty()
&& self.exclude.iter().any(|e| e.eq_ignore_ascii_case(value))
{
if !self.exclude.is_empty() && self.exclude.iter().any(|e| e.eq_ignore_ascii_case(value)) {
return false;
}
true
@@ -263,9 +259,11 @@ impl ArchiveRules {
}
fn matches_any_regex(patterns: &[String], value: &str) -> bool {
patterns
.iter()
.any(|p| regex::Regex::new(p).map(|re| re.is_match(value)).unwrap_or(false))
patterns.iter().any(|p| {
regex::Regex::new(p)
.map(|re| re.is_match(value))
.unwrap_or(false)
})
}
fn validate_patterns(patterns: &[String], field_name: &str) -> Result<(), String> {
@@ -584,6 +582,7 @@ impl Account {
.map(|account: AccountModel| MinimalAccount {
id: account.id,
email: account.email,
name: account.account_name,
})
.collect::<Vec<MinimalAccount>>();
Ok(result)
@@ -868,12 +867,7 @@ mod tests {
enabled: false,
..Default::default()
};
assert!(rules.should_archive(
Some("spam@x.com"),
Some("BUY NOW"),
999,
false
));
assert!(rules.should_archive(Some("spam@x.com"), Some("BUY NOW"), 999, false));
}
#[test]
+12 -3
View File
@@ -19,7 +19,9 @@
use std::str::FromStr;
use crate::account::entity::ImapConfig;
use crate::account::migration::{AccountModel, AccountType, ArchiveRules, ExtractionRules, QuotaWindow};
use crate::account::migration::{
AccountModel, AccountType, ArchiveRules, ExtractionRules, QuotaWindow,
};
use crate::account::since::{DateSince, RelativeDate};
use crate::error::code::ErrorCode;
use crate::error::BichonResult;
@@ -114,7 +116,10 @@ impl AccountCreateRequest {
}
if let Some(ref rules) = self.extraction_rules {
rules.validate().map_err(|e| {
raise_error!(format!("extraction_rules: {}", e), ErrorCode::InvalidParameter)
raise_error!(
format!("extraction_rules: {}", e),
ErrorCode::InvalidParameter
)
})?;
}
if let Some(ref rules) = self.archive_rules {
@@ -259,7 +264,10 @@ impl AccountUpdateRequest {
}
if let Some(ref rules) = self.extraction_rules {
rules.validate().map_err(|e| {
raise_error!(format!("extraction_rules: {}", e), ErrorCode::InvalidParameter)
raise_error!(
format!("extraction_rules: {}", e),
ErrorCode::InvalidParameter
)
})?;
}
if let Some(ref rules) = self.archive_rules {
@@ -293,6 +301,7 @@ fn validate_cron_expression(expr: &str) -> BichonResult<()> {
pub struct MinimalAccount {
pub id: u64,
pub email: String,
pub name: Option<String>,
}
pub fn filter_accessible_accounts<'a>(
+2
View File
@@ -296,6 +296,7 @@ async fn extract_envelope_core(
account_email: None,
mailbox_name: None,
content_hash: email_content_hash.clone(),
account_name: None,
};
// 'attachments' contains both regular and inline attachments
let ea = EnvelopeWithAttachments {
@@ -390,6 +391,7 @@ pub fn extract_envelope_from_nested_message(
regular_attachment_count: Default::default(),
tags: Default::default(),
account_email: Default::default(),
account_name: Default::default(),
mailbox_name: Default::default(),
content_hash: Default::default(),
};
+1
View File
@@ -404,6 +404,7 @@ impl NewIndexWriter {
regular_attachment_count: attachment_docs.len(),
tags: None,
account_email: None,
account_name: None,
mailbox_name: None,
content_hash: email_content_hash,
};
+1
View File
@@ -27,6 +27,7 @@ pub struct Envelope {
pub message_id: String,
pub account_id: u64,
pub account_email: Option<String>,
pub account_name: Option<String>,
pub mailbox_id: u64,
pub mailbox_name: Option<String>,
pub uid: u32,
+2 -1
View File
@@ -155,7 +155,8 @@ impl EnvelopeWithAttachments {
id: extract_string_field(doc, fields.f_id, F_ID)?,
message_id: extract_string_field(doc, fields.f_message_id, F_MESSAGE_ID)?,
account_id,
account_email: Some(account.email),
account_email: Some(account.email), //https://github.com/rustmailer/bichon/issues/306
account_name: account.account_name,
mailbox_id,
mailbox_name: Some(mailbox.name),
uid: extract_u64_field(doc, fields.f_uid, F_UID)? as u32,
+1
View File
@@ -23,6 +23,7 @@ import { PaginatedResponse } from "..";
export interface MinimalAccount {
id: number;
email: string;
name?: string;
}
export const minimal_account_list = async () => {
+1
View File
@@ -32,6 +32,7 @@ export interface EmailEnvelope {
account_id: number;
mailbox_id: number;
account_email: string;
account_name?: string;
mailbox_name: string;
uid: number;
subject: string;
+35 -10
View File
@@ -25,6 +25,11 @@ import LongText from '@/components/long-text';
import { getToken } from '@/stores/authStore';
import { useNavigate } from '@tanstack/react-router';
import useMinimalAccountList from '@/hooks/use-minimal-account-list';
import {
Tooltip as TooltipUI,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
interface DailyActivity {
date: string;
@@ -128,6 +133,12 @@ export default function MailArchiveDashboard() {
return account ? account.id : null;
};
const getAccountNameByEmail = (email: string): string | null => {
if (!minimalList) return null;
const account = minimalList.find(a => a.email === email);
return account?.name || null;
};
const handleQuickSearch = (filter: Record<string, any>) => {
navigate({
to: '/search',
@@ -519,16 +530,30 @@ export default function MailArchiveDashboard() {
<div className="text-xs flex flex-wrap gap-x-1 min-w-0 flex-1">
<span className="flex items-center">
<LongText className="max-w-[180px] md:max-w-[160px] lg:max-w-[200px] xl:max-w-[220px]">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleQuickSearch({ account_ids: [getAccountIdByEmail(acc.key) || 0] })
}}
className="hover:text-primary hover:underline transition-colors"
>
{acc.key}
</button>
{(() => {
const name = getAccountNameByEmail(acc.key);
const btn = (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleQuickSearch({ account_ids: [getAccountIdByEmail(acc.key) || 0] })
}}
className="hover:text-primary hover:underline transition-colors"
>
{name || acc.key}
</button>
);
if (name) {
return (
<TooltipUI>
<TooltipTrigger asChild>{btn}</TooltipTrigger>
<TooltipContent side="top">{acc.key}</TooltipContent>
</TooltipUI>
);
}
return btn;
})()}
</LongText>
</span>
</div>
+22 -3
View File
@@ -31,6 +31,11 @@ import {
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import useMinimalAccountList from '@/hooks/use-minimal-account-list'
import { cn } from '@/lib/utils'
@@ -83,6 +88,7 @@ export function AccountPopover() {
.filter(a =>
!q ||
a.email.toLowerCase().includes(q) ||
a.name?.toLowerCase().includes(q) ||
String(a.id).includes(q)
)
.sort((a, b) => {
@@ -184,9 +190,22 @@ export function AccountPopover() {
className="flex-1 truncate text-xs cursor-pointer"
>
<div className="flex items-center gap-2">
<span className="truncate">
{account.email}
</span>
{account.name ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="truncate">
{account.name}
</span>
</TooltipTrigger>
<TooltipContent side="top">
{account.email}
</TooltipContent>
</Tooltip>
) : (
<span className="truncate">
{account.email}
</span>
)}
<span className="text-[10px] text-muted-foreground">
#{account.id}
</span>
+2 -2
View File
@@ -89,9 +89,9 @@ export function MailListTable({
accessorKey: "source",
header: t('search.source'),
cell: ({ row }) => {
const { from, account_email, mailbox_name, account_id, mailbox_id } = row.original;
const { from, account_email, account_name, mailbox_name, account_id, mailbox_id } = row.original;
const { setFilter } = useSearchMessages();
const accountPrefix = account_email.split('@')[0];
const accountPrefix = account_name ?? account_email.split('@')[0];//https://github.com/rustmailer/bichon/issues/306
return (
<div className="flex flex-col py-1.5 min-w-0 group">
-278
View File
@@ -1,278 +0,0 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { cn, dateFnsLocaleMap, formatBytes } from "@/lib/utils"
import { formatDistanceToNow } from "date-fns"
import { MailIcon, MoreVertical, Paperclip, TagIcon, Trash2 } from "lucide-react"
import { Skeleton } from "@/components/ui/skeleton"
import { Checkbox } from "@/components/ui/checkbox"
import { EmailEnvelope } from "@/api"
import { useSearchContext } from "./context"
import { MailBulkActions } from "./bulk-actions"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { useTranslation } from 'react-i18next'
import { enUS } from "date-fns/locale"
interface MailListProps {
items: EmailEnvelope[]
isLoading: boolean
onEnvelopeChanged: (envelope: EmailEnvelope) => void
}
export function MailList({
items,
isLoading,
onEnvelopeChanged
}: MailListProps) {
const { t, i18n } = useTranslation()
const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS;
const { setOpen, currentEnvelope, setCurrentEnvelope, selected, setSelected, setToDelete } = useSearchContext()
const handleToggleAll = () => {
const total = Array.from(selected.values())
.reduce((sum, set) => sum + set.size, 0);
if (total === items.length && items.length > 0) {
setSelected(new Map());
} else {
setSelected(prev => {
const next = new Map(prev);
for (const item of items) {
const set = new Set(next.get(item.account_id) || []);
set.add(item.id);
next.set(item.account_id, set);
}
return next;
});
}
}
const toggleToDelete = (accountId: number, mailId: string) => {
setToDelete(prev => {
const next = new Map(prev);
const set = new Set(next.get(accountId) || []);
if (set.has(mailId)) {
set.delete(mailId);
if (set.size === 0) next.delete(accountId);
else next.set(accountId, set);
} else {
set.add(mailId);
next.set(accountId, set);
}
return next;
});
};
const toggleSelected = (accountId: number, mailId: string) => {
setSelected(prev => {
const next = new Map(prev);
const set = new Set(next.get(accountId) || []);
if (set.has(mailId)) {
set.delete(mailId);
if (set.size === 0) next.delete(accountId);
else next.set(accountId, set);
} else {
set.add(mailId);
next.set(accountId, set);
}
return next;
});
}
const totalSelected = Array.from(selected.values())
.reduce((sum, set) => sum + set.size, 0);
const hasSelected = (accountId: number, mailId: string) => {
return selected.get(accountId)?.has(mailId) ?? false;
}
const handleDelete = (envelope: EmailEnvelope) => {
setToDelete(new Map());
toggleToDelete(envelope.account_id, envelope.id)
setOpen("delete")
}
if (isLoading) {
return (
<div className="divide-y divide-border">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex items-center gap-2 px-2 py-1.5">
<Skeleton className="h-3 w-3" />
<Skeleton className="h-3 w-3 rounded-full" />
<Skeleton className="h-3 flex-1" />
<Skeleton className="h-2.5 w-16" />
</div>
))}
</div>
)
}
return (
<div className="divide-y divide-border">
{items.length > 0 && (
<div className="flex items-center gap-2 px-2 py-1 bg-muted/30">
<Checkbox
checked={
totalSelected === items.length && items.length > 0
? true
: totalSelected > 0
? "indeterminate"
: false
}
onCheckedChange={handleToggleAll}
className="h-4 w-4"
/>
<span className="text-xs text-muted-foreground">
{totalSelected > 0
? `${t('search.bulkActions.selected', { count: totalSelected })}`
: t('common.selectAll')}
</span>
</div>
)}
{items.map((item, index) => {
const hasAttachments = item.regular_attachment_count > 0
const isSelectedRow = currentEnvelope?.id === item.id
const isChecked = hasSelected(item.account_id, item.id)
return (
<div
key={index}
className={cn(
"flex items-center gap-2 px-2 py-1.5 cursor-pointer transition-colors",
"hover:bg-accent/50",
isSelectedRow && "bg-accent"
)}
onClick={(e) => {
const target = e.target as HTMLElement
if (target.closest('input[type="checkbox"], button')) return
onEnvelopeChanged(item)
}}
>
<Checkbox
checked={isChecked}
onCheckedChange={() => toggleSelected(item.account_id, item.id)}
onClick={(e) => e.stopPropagation()}
className="h-4 w-4 shrink-0"
/>
<MailIcon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0 grid grid-cols-1 sm:grid-cols-12 gap-1 sm:gap-0">
<div className="col-span-1 sm:col-span-8 flex flex-col min-w-0 gap-0.5">
<div className="flex items-center gap-1 min-w-0">
<p className="text-sm font-medium truncate">{item.from}</p>
<h3 className="text-sm text-muted-foreground truncate hidden sm:block">
{item.subject}
</h3>
</div>
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground/60">
<span className="truncate">{item.account_email}</span>
<span className="scale-75 opacity-50"></span>
<span className="font-medium text-primary/70">{item.mailbox_name}</span>
</div>
<h3 className="text-sm text-muted-foreground truncate sm:hidden">
{item.subject}
</h3>
<div className="flex flex-wrap gap-1 mt-0.25">
{item.tags?.map((tag, i) => (
<Badge className="px-1 py-0.5 text-[10px] h-auto leading-none" key={i}>{tag}</Badge>
))}
</div>
</div>
<div className="col-span-1 sm:col-span-4 flex items-center justify-end gap-1 text-xs text-muted-foreground">
{hasAttachments && (
<div className="flex items-center gap-1">
<Paperclip className="h-3 w-3" />
<span>{item.regular_attachment_count}</span>
</div>
)}
<span className="hidden md:inline">{formatBytes(item.size)}</span>
<span className={cn(isSelectedRow ? "text-foreground font-medium" : "text-muted-foreground")}>
{item.date && formatDistanceToNow(new Date(item.date), { addSuffix: true, locale })}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 p-0 hover:bg-muted rounded-md"
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem
onClick={(e) => e.stopPropagation()}
onSelect={(e) => {
e.stopPropagation();
setCurrentEnvelope(item);
setOpen("edit-tags");
}}
>
<TagIcon className="ml-2 h-3.5 w-3.5" />
{t('search.editTag')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => e.stopPropagation()}
onSelect={(e) => {
e.stopPropagation();
setCurrentEnvelope(item);
setOpen("restore");
}}
>
<TagIcon className="ml-2 h-3.5 w-3.5" />
{t('restore_message.restore_to_imap')}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => e.stopPropagation()}
onSelect={(e) => {
e.stopPropagation();
handleDelete(item);
}}
>
<Trash2 className="ml-2 h-3.5 w-3.5" />
{t('common.delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
)
})}
{totalSelected > 0 && <MailBulkActions />}
</div>
)
}