From 8542ba0d2881534379989dbfbcd5c7a3d0bd74c7 Mon Sep 17 00:00:00 2001 From: rustmailer Date: Thu, 25 Jun 2026 02:00:20 +0800 Subject: [PATCH] feat: Display Name / Alias for IMAP Accounts #306 --- crates/core/src/account/migration.rs | 24 +- crates/core/src/account/payload.rs | 15 +- crates/core/src/envelope/extractor.rs | 2 + crates/core/src/migrate/store.rs | 1 + crates/core/src/store/envelope.rs | 1 + crates/core/src/store/tantivy/model.rs | 3 +- web/src/api/account/api.ts | 1 + web/src/api/index.ts | 1 + web/src/features/dashboard/index.tsx | 45 +++- web/src/features/search/account-popover.tsx | 25 +- web/src/features/search/mail-list-table.tsx | 4 +- web/src/features/search/mail-list.tsx | 278 -------------------- 12 files changed, 88 insertions(+), 312 deletions(-) delete mode 100644 web/src/features/search/mail-list.tsx diff --git a/crates/core/src/account/migration.rs b/crates/core/src/account/migration.rs index 0344d57..cb3ea52 100644 --- a/crates/core/src/account/migration.rs +++ b/crates/core/src/account/migration.rs @@ -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::>(); 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] diff --git a/crates/core/src/account/payload.rs b/crates/core/src/account/payload.rs index 798a7aa..b2c55f7 100644 --- a/crates/core/src/account/payload.rs +++ b/crates/core/src/account/payload.rs @@ -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, } pub fn filter_accessible_accounts<'a>( diff --git a/crates/core/src/envelope/extractor.rs b/crates/core/src/envelope/extractor.rs index c412c7d..88195a7 100644 --- a/crates/core/src/envelope/extractor.rs +++ b/crates/core/src/envelope/extractor.rs @@ -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(), }; diff --git a/crates/core/src/migrate/store.rs b/crates/core/src/migrate/store.rs index 73d7bdb..8dc203f 100644 --- a/crates/core/src/migrate/store.rs +++ b/crates/core/src/migrate/store.rs @@ -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, }; diff --git a/crates/core/src/store/envelope.rs b/crates/core/src/store/envelope.rs index 6767f29..ca39460 100644 --- a/crates/core/src/store/envelope.rs +++ b/crates/core/src/store/envelope.rs @@ -27,6 +27,7 @@ pub struct Envelope { pub message_id: String, pub account_id: u64, pub account_email: Option, + pub account_name: Option, pub mailbox_id: u64, pub mailbox_name: Option, pub uid: u32, diff --git a/crates/core/src/store/tantivy/model.rs b/crates/core/src/store/tantivy/model.rs index f05fffa..61364c3 100644 --- a/crates/core/src/store/tantivy/model.rs +++ b/crates/core/src/store/tantivy/model.rs @@ -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, diff --git a/web/src/api/account/api.ts b/web/src/api/account/api.ts index 5fb2218..735fef4 100644 --- a/web/src/api/account/api.ts +++ b/web/src/api/account/api.ts @@ -23,6 +23,7 @@ import { PaginatedResponse } from ".."; export interface MinimalAccount { id: number; email: string; + name?: string; } export const minimal_account_list = async () => { diff --git a/web/src/api/index.ts b/web/src/api/index.ts index 9d06c44..3484257 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -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; diff --git a/web/src/features/dashboard/index.tsx b/web/src/features/dashboard/index.tsx index f303f05..71c8463 100644 --- a/web/src/features/dashboard/index.tsx +++ b/web/src/features/dashboard/index.tsx @@ -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) => { navigate({ to: '/search', @@ -519,16 +530,30 @@ export default function MailArchiveDashboard() {
- + {(() => { + const name = getAccountNameByEmail(acc.key); + const btn = ( + + ); + if (name) { + return ( + + {btn} + {acc.key} + + ); + } + return btn; + })()}
diff --git a/web/src/features/search/account-popover.tsx b/web/src/features/search/account-popover.tsx index 0e7e72b..5b02a4c 100644 --- a/web/src/features/search/account-popover.tsx +++ b/web/src/features/search/account-popover.tsx @@ -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" >
- - {account.email} - + {account.name ? ( + + + + {account.name} + + + + {account.email} + + + ) : ( + + {account.email} + + )} #{account.id} diff --git a/web/src/features/search/mail-list-table.tsx b/web/src/features/search/mail-list-table.tsx index bd13c42..196b959 100755 --- a/web/src/features/search/mail-list-table.tsx +++ b/web/src/features/search/mail-list-table.tsx @@ -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 (
diff --git a/web/src/features/search/mail-list.tsx b/web/src/features/search/mail-list.tsx deleted file mode 100644 index a3c747f..0000000 --- a/web/src/features/search/mail-list.tsx +++ /dev/null @@ -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 . - - -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 ( -
- {Array.from({ length: 8 }).map((_, i) => ( -
- - - - -
- ))} -
- ) - } - - return ( -
- {items.length > 0 && ( -
- 0 - ? true - : totalSelected > 0 - ? "indeterminate" - : false - } - onCheckedChange={handleToggleAll} - className="h-4 w-4" - /> - - {totalSelected > 0 - ? `${t('search.bulkActions.selected', { count: totalSelected })}` - : t('common.selectAll')} - -
- )} - - {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 ( -
{ - const target = e.target as HTMLElement - if (target.closest('input[type="checkbox"], button')) return - onEnvelopeChanged(item) - }} - > - toggleSelected(item.account_id, item.id)} - onClick={(e) => e.stopPropagation()} - className="h-4 w-4 shrink-0" - /> - - -
- -
-
-

{item.from}

-

- {item.subject} -

-
-
- {item.account_email} - - {item.mailbox_name} -
-

- {item.subject} -

- -
- {item.tags?.map((tag, i) => ( - {tag} - ))} -
-
-
- - {hasAttachments && ( -
- - {item.regular_attachment_count} -
- )} - - {formatBytes(item.size)} - - - {item.date && formatDistanceToNow(new Date(item.date), { addSuffix: true, locale })} - - - - - - - - - e.stopPropagation()} - onSelect={(e) => { - e.stopPropagation(); - setCurrentEnvelope(item); - setOpen("edit-tags"); - }} - > - - {t('search.editTag')} - - e.stopPropagation()} - onSelect={(e) => { - e.stopPropagation(); - setCurrentEnvelope(item); - setOpen("restore"); - }} - > - - {t('restore_message.restore_to_imap')} - - e.stopPropagation()} - onSelect={(e) => { - e.stopPropagation(); - handleDelete(item); - }} - > - - {t('common.delete')} - - - -
-
-
- ) - })} - {totalSelected > 0 && } -
- ) -}