mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
feat: Display Name / Alias for IMAP Accounts #306
This commit is contained in:
@@ -87,14 +87,10 @@ impl FilterRule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn matches_exact(&self, value: &str) -> bool {
|
fn matches_exact(&self, value: &str) -> bool {
|
||||||
if !self.include.is_empty()
|
if !self.include.is_empty() && !self.include.iter().any(|e| e.eq_ignore_ascii_case(value)) {
|
||||||
&& !self.include.iter().any(|e| e.eq_ignore_ascii_case(value))
|
|
||||||
{
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if !self.exclude.is_empty()
|
if !self.exclude.is_empty() && self.exclude.iter().any(|e| e.eq_ignore_ascii_case(value)) {
|
||||||
&& self.exclude.iter().any(|e| e.eq_ignore_ascii_case(value))
|
|
||||||
{
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
@@ -263,9 +259,11 @@ impl ArchiveRules {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn matches_any_regex(patterns: &[String], value: &str) -> bool {
|
fn matches_any_regex(patterns: &[String], value: &str) -> bool {
|
||||||
patterns
|
patterns.iter().any(|p| {
|
||||||
.iter()
|
regex::Regex::new(p)
|
||||||
.any(|p| regex::Regex::new(p).map(|re| re.is_match(value)).unwrap_or(false))
|
.map(|re| re.is_match(value))
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_patterns(patterns: &[String], field_name: &str) -> Result<(), String> {
|
fn validate_patterns(patterns: &[String], field_name: &str) -> Result<(), String> {
|
||||||
@@ -584,6 +582,7 @@ impl Account {
|
|||||||
.map(|account: AccountModel| MinimalAccount {
|
.map(|account: AccountModel| MinimalAccount {
|
||||||
id: account.id,
|
id: account.id,
|
||||||
email: account.email,
|
email: account.email,
|
||||||
|
name: account.account_name,
|
||||||
})
|
})
|
||||||
.collect::<Vec<MinimalAccount>>();
|
.collect::<Vec<MinimalAccount>>();
|
||||||
Ok(result)
|
Ok(result)
|
||||||
@@ -868,12 +867,7 @@ mod tests {
|
|||||||
enabled: false,
|
enabled: false,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
assert!(rules.should_archive(
|
assert!(rules.should_archive(Some("spam@x.com"), Some("BUY NOW"), 999, false));
|
||||||
Some("spam@x.com"),
|
|
||||||
Some("BUY NOW"),
|
|
||||||
999,
|
|
||||||
false
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -19,7 +19,9 @@
|
|||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
use crate::account::entity::ImapConfig;
|
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::account::since::{DateSince, RelativeDate};
|
||||||
use crate::error::code::ErrorCode;
|
use crate::error::code::ErrorCode;
|
||||||
use crate::error::BichonResult;
|
use crate::error::BichonResult;
|
||||||
@@ -114,7 +116,10 @@ impl AccountCreateRequest {
|
|||||||
}
|
}
|
||||||
if let Some(ref rules) = self.extraction_rules {
|
if let Some(ref rules) = self.extraction_rules {
|
||||||
rules.validate().map_err(|e| {
|
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 {
|
if let Some(ref rules) = self.archive_rules {
|
||||||
@@ -259,7 +264,10 @@ impl AccountUpdateRequest {
|
|||||||
}
|
}
|
||||||
if let Some(ref rules) = self.extraction_rules {
|
if let Some(ref rules) = self.extraction_rules {
|
||||||
rules.validate().map_err(|e| {
|
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 {
|
if let Some(ref rules) = self.archive_rules {
|
||||||
@@ -293,6 +301,7 @@ fn validate_cron_expression(expr: &str) -> BichonResult<()> {
|
|||||||
pub struct MinimalAccount {
|
pub struct MinimalAccount {
|
||||||
pub id: u64,
|
pub id: u64,
|
||||||
pub email: String,
|
pub email: String,
|
||||||
|
pub name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn filter_accessible_accounts<'a>(
|
pub fn filter_accessible_accounts<'a>(
|
||||||
|
|||||||
@@ -296,6 +296,7 @@ async fn extract_envelope_core(
|
|||||||
account_email: None,
|
account_email: None,
|
||||||
mailbox_name: None,
|
mailbox_name: None,
|
||||||
content_hash: email_content_hash.clone(),
|
content_hash: email_content_hash.clone(),
|
||||||
|
account_name: None,
|
||||||
};
|
};
|
||||||
// 'attachments' contains both regular and inline attachments
|
// 'attachments' contains both regular and inline attachments
|
||||||
let ea = EnvelopeWithAttachments {
|
let ea = EnvelopeWithAttachments {
|
||||||
@@ -390,6 +391,7 @@ pub fn extract_envelope_from_nested_message(
|
|||||||
regular_attachment_count: Default::default(),
|
regular_attachment_count: Default::default(),
|
||||||
tags: Default::default(),
|
tags: Default::default(),
|
||||||
account_email: Default::default(),
|
account_email: Default::default(),
|
||||||
|
account_name: Default::default(),
|
||||||
mailbox_name: Default::default(),
|
mailbox_name: Default::default(),
|
||||||
content_hash: Default::default(),
|
content_hash: Default::default(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -404,6 +404,7 @@ impl NewIndexWriter {
|
|||||||
regular_attachment_count: attachment_docs.len(),
|
regular_attachment_count: attachment_docs.len(),
|
||||||
tags: None,
|
tags: None,
|
||||||
account_email: None,
|
account_email: None,
|
||||||
|
account_name: None,
|
||||||
mailbox_name: None,
|
mailbox_name: None,
|
||||||
content_hash: email_content_hash,
|
content_hash: email_content_hash,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ pub struct Envelope {
|
|||||||
pub message_id: String,
|
pub message_id: String,
|
||||||
pub account_id: u64,
|
pub account_id: u64,
|
||||||
pub account_email: Option<String>,
|
pub account_email: Option<String>,
|
||||||
|
pub account_name: Option<String>,
|
||||||
pub mailbox_id: u64,
|
pub mailbox_id: u64,
|
||||||
pub mailbox_name: Option<String>,
|
pub mailbox_name: Option<String>,
|
||||||
pub uid: u32,
|
pub uid: u32,
|
||||||
|
|||||||
@@ -155,7 +155,8 @@ impl EnvelopeWithAttachments {
|
|||||||
id: extract_string_field(doc, fields.f_id, F_ID)?,
|
id: extract_string_field(doc, fields.f_id, F_ID)?,
|
||||||
message_id: extract_string_field(doc, fields.f_message_id, F_MESSAGE_ID)?,
|
message_id: extract_string_field(doc, fields.f_message_id, F_MESSAGE_ID)?,
|
||||||
account_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_id,
|
||||||
mailbox_name: Some(mailbox.name),
|
mailbox_name: Some(mailbox.name),
|
||||||
uid: extract_u64_field(doc, fields.f_uid, F_UID)? as u32,
|
uid: extract_u64_field(doc, fields.f_uid, F_UID)? as u32,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { PaginatedResponse } from "..";
|
|||||||
export interface MinimalAccount {
|
export interface MinimalAccount {
|
||||||
id: number;
|
id: number;
|
||||||
email: string;
|
email: string;
|
||||||
|
name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const minimal_account_list = async () => {
|
export const minimal_account_list = async () => {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export interface EmailEnvelope {
|
|||||||
account_id: number;
|
account_id: number;
|
||||||
mailbox_id: number;
|
mailbox_id: number;
|
||||||
account_email: string;
|
account_email: string;
|
||||||
|
account_name?: string;
|
||||||
mailbox_name: string;
|
mailbox_name: string;
|
||||||
uid: number;
|
uid: number;
|
||||||
subject: string;
|
subject: string;
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ import LongText from '@/components/long-text';
|
|||||||
import { getToken } from '@/stores/authStore';
|
import { getToken } from '@/stores/authStore';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import useMinimalAccountList from '@/hooks/use-minimal-account-list';
|
import useMinimalAccountList from '@/hooks/use-minimal-account-list';
|
||||||
|
import {
|
||||||
|
Tooltip as TooltipUI,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip';
|
||||||
|
|
||||||
interface DailyActivity {
|
interface DailyActivity {
|
||||||
date: string;
|
date: string;
|
||||||
@@ -128,6 +133,12 @@ export default function MailArchiveDashboard() {
|
|||||||
return account ? account.id : null;
|
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>) => {
|
const handleQuickSearch = (filter: Record<string, any>) => {
|
||||||
navigate({
|
navigate({
|
||||||
to: '/search',
|
to: '/search',
|
||||||
@@ -519,6 +530,9 @@ export default function MailArchiveDashboard() {
|
|||||||
<div className="text-xs flex flex-wrap gap-x-1 min-w-0 flex-1">
|
<div className="text-xs flex flex-wrap gap-x-1 min-w-0 flex-1">
|
||||||
<span className="flex items-center">
|
<span className="flex items-center">
|
||||||
<LongText className="max-w-[180px] md:max-w-[160px] lg:max-w-[200px] xl:max-w-[220px]">
|
<LongText className="max-w-[180px] md:max-w-[160px] lg:max-w-[200px] xl:max-w-[220px]">
|
||||||
|
{(() => {
|
||||||
|
const name = getAccountNameByEmail(acc.key);
|
||||||
|
const btn = (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -527,8 +541,19 @@ export default function MailArchiveDashboard() {
|
|||||||
}}
|
}}
|
||||||
className="hover:text-primary hover:underline transition-colors"
|
className="hover:text-primary hover:underline transition-colors"
|
||||||
>
|
>
|
||||||
{acc.key}
|
{name || acc.key}
|
||||||
</button>
|
</button>
|
||||||
|
);
|
||||||
|
if (name) {
|
||||||
|
return (
|
||||||
|
<TooltipUI>
|
||||||
|
<TooltipTrigger asChild>{btn}</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">{acc.key}</TooltipContent>
|
||||||
|
</TooltipUI>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return btn;
|
||||||
|
})()}
|
||||||
</LongText>
|
</LongText>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ import {
|
|||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from '@/components/ui/popover'
|
} from '@/components/ui/popover'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip'
|
||||||
|
|
||||||
import useMinimalAccountList from '@/hooks/use-minimal-account-list'
|
import useMinimalAccountList from '@/hooks/use-minimal-account-list'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -83,6 +88,7 @@ export function AccountPopover() {
|
|||||||
.filter(a =>
|
.filter(a =>
|
||||||
!q ||
|
!q ||
|
||||||
a.email.toLowerCase().includes(q) ||
|
a.email.toLowerCase().includes(q) ||
|
||||||
|
a.name?.toLowerCase().includes(q) ||
|
||||||
String(a.id).includes(q)
|
String(a.id).includes(q)
|
||||||
)
|
)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
@@ -184,9 +190,22 @@ export function AccountPopover() {
|
|||||||
className="flex-1 truncate text-xs cursor-pointer"
|
className="flex-1 truncate text-xs cursor-pointer"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{account.name ? (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="truncate">
|
||||||
|
{account.name}
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">
|
||||||
|
{account.email}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{account.email}
|
{account.email}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
<span className="text-[10px] text-muted-foreground">
|
<span className="text-[10px] text-muted-foreground">
|
||||||
#{account.id}
|
#{account.id}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -89,9 +89,9 @@ export function MailListTable({
|
|||||||
accessorKey: "source",
|
accessorKey: "source",
|
||||||
header: t('search.source'),
|
header: t('search.source'),
|
||||||
cell: ({ row }) => {
|
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 { setFilter } = useSearchMessages();
|
||||||
const accountPrefix = account_email.split('@')[0];
|
const accountPrefix = account_name ?? account_email.split('@')[0];//https://github.com/rustmailer/bichon/issues/306
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col py-1.5 min-w-0 group">
|
<div className="flex flex-col py-1.5 min-w-0 group">
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user