Merge remote-tracking branch 'origin/main' into feature/scheduled-send

# Conflicts:
#	app/[locale]/page.tsx
#	components/email/email-composer.tsx
#	components/email/email-viewer.tsx
This commit is contained in:
Lucas Gaitzsch
2026-05-07 18:01:41 +02:00
120 changed files with 7033 additions and 4882 deletions
+321 -248
View File
@@ -1,6 +1,7 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { createPortal } from 'react-dom';
import {
ArrowRight,
Calendar,
@@ -36,6 +37,7 @@ import {
} from '@/lib/calendar-invitation';
import { cn } from '@/lib/utils';
import { sanitizeColor } from '@/components/calendar/event-card';
import { RecipientPopover } from './recipient-popover';
interface InvitationChangeItem {
label: string;
@@ -326,25 +328,26 @@ function buildParticipantsForRsvp(
);
}
function getMethodAccentClass(method: InvitationMethod, actorStatus?: string | null): string {
function getMethodIconTone(method: InvitationMethod, actorStatus?: string | null): string {
switch (method) {
case 'cancel':
case 'declinecounter':
return 'border-l-red-500 dark:border-l-red-400';
case 'request':
case 'add':
return 'border-l-blue-500 dark:border-l-blue-400';
return 'bg-destructive/15 text-destructive';
case 'counter':
return 'border-l-amber-500 dark:border-l-amber-400';
return 'bg-warning/15 text-warning';
case 'reply':
switch (actorStatus) {
case 'accepted': return 'border-l-green-500 dark:border-l-green-400';
case 'tentative': return 'border-l-amber-500 dark:border-l-amber-400';
case 'declined': return 'border-l-red-500 dark:border-l-red-400';
default: return 'border-l-blue-500 dark:border-l-blue-400';
case 'accepted': return 'bg-success/15 text-success';
case 'tentative': return 'bg-warning/15 text-warning';
case 'declined': return 'bg-destructive/15 text-destructive';
default: return 'bg-primary/15 text-primary';
}
case 'request':
case 'add':
case 'publish':
return 'bg-primary/15 text-primary';
default:
return 'border-l-slate-400 dark:border-l-slate-500';
return 'bg-muted text-muted-foreground';
}
}
@@ -371,9 +374,11 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
const [actionError, setActionError] = useState<string | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [showCalendarPicker, setShowCalendarPicker] = useState(false);
const [pickerPosition, setPickerPosition] = useState<{ top: number; left: number } | null>(null);
const pickerTriggerRef = useRef<HTMLButtonElement>(null);
const [selectedCalendarId, setSelectedCalendarId] = useState<string>('');
const [rawIcsMethod, setRawIcsMethod] = useState<InvitationMethod>('unknown');
const [isCollapsed, setIsCollapsed] = useState(true);
const [isCollapsed, setIsCollapsed] = useState(false);
const attachment = findCalendarAttachment(email);
@@ -437,6 +442,17 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
}
}, [calendars, selectedCalendarId]);
useEffect(() => {
if (!showCalendarPicker) return;
const close = () => setShowCalendarPicker(false);
window.addEventListener('scroll', close, true);
window.addEventListener('resize', close);
return () => {
window.removeEventListener('scroll', close, true);
window.removeEventListener('resize', close);
};
}, [showCalendarPicker]);
if (!attachment || !calendarInvitationParsingEnabled) return null;
const detectedMethod = parsedEvent ? getInvitationMethod(parsedEvent, { email, attachment }) : 'unknown';
@@ -444,8 +460,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
const summary = parsedEvent ? formatEventSummary(parsedEvent) : null;
const isCancellation = method === 'cancel';
const isResponseOnly = method === 'reply' || method === 'refresh' || method === 'counter' || method === 'declinecounter';
const canCollapse = method === 'reply';
const showDetails = !canCollapse || !isCollapsed;
const showDetails = !isCollapsed;
const allowsRsvp = method === 'request';
const allowsImport = method === 'request' || method === 'publish' || method === 'add' || method === 'unknown';
@@ -486,7 +501,6 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
const actorName = actorSummary?.name || actorSummary?.email || t('actor_unknown');
const actorStatus = getParticipationLabel(t, actorSummary?.participationStatus ?? null);
const actorMessage = actorSummary ? getActorMessage(t, method, actorName, actorStatus) : null;
const actionFeedback = actionNotice;
// For REQUEST method, allow RSVP even if we can't find the user in participants:
// the email was sent TO the user, so they are an attendee. handleRsvp handles
// the import-then-find-participant flow for this case.
@@ -721,130 +735,167 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
}
};
const accentClass = getMethodAccentClass(method, actorSummary?.participationStatus);
if (state === 'loading') {
return (
<div className="rounded-lg border border-border bg-muted/20 px-4 py-3 flex items-center gap-2.5">
<Calendar className="w-4 h-4 text-primary flex-shrink-0" />
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">{t('loading')}</span>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-muted text-muted-foreground flex items-center justify-center flex-shrink-0 shadow-sm">
<Calendar className="w-5 h-5" />
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
<span>{t('loading')}</span>
</div>
</div>
);
}
if (state === 'error') {
return (
<div className="rounded-lg border border-destructive/20 bg-destructive/10 px-4 py-3 flex items-center gap-2.5">
<AlertCircle className="w-4 h-4 text-destructive flex-shrink-0" />
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-destructive/15 text-destructive flex items-center justify-center flex-shrink-0 shadow-sm">
<AlertCircle className="w-5 h-5" />
</div>
<span className="text-sm text-destructive">{t('parse_error')}</span>
</div>
);
}
const iconTone = getMethodIconTone(method, actorSummary?.participationStatus);
const hasStatusPills = Boolean(
existingEvent
|| userIsOrganizer
|| (participationLabel && myParticipant)
|| actionNotice
|| (parsedEvent?.status && parsedEvent.status !== 'confirmed')
);
const showActionsRow = showDetails && (
canRespond
|| (supportsCalendar && !existingEvent && allowsImport && !isResponseOnly && !isCancellation)
|| canApplyProposal
|| (supportsCalendar && (existingEvent || parsedEvent))
|| !supportsCalendar
);
return (
<div className={cn("rounded-lg border border-border overflow-hidden border-l-4", accentClass)}>
{/* Header */}
<div className="flex items-center justify-between px-4 py-2.5 bg-muted/30 border-b border-border">
<div className="flex items-center gap-2 min-w-0">
{isCancellation ? (
<CalendarX className="w-4 h-4 text-destructive flex-shrink-0" />
) : (
<Calendar className="w-4 h-4 text-primary flex-shrink-0" />
)}
<span className="text-sm font-medium text-foreground truncate">{bannerTitle}</span>
</div>
{canCollapse && (
<button
type="button"
onClick={() => setIsCollapsed((prev) => !prev)}
aria-expanded={!isCollapsed}
aria-label={isCollapsed ? t('expand') : t('collapse')}
title={isCollapsed ? t('expand') : t('collapse')}
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors flex-shrink-0"
>
{isCollapsed ? <ChevronDown className="w-4 h-4" /> : <ChevronUp className="w-4 h-4" />}
</button>
<div
className={cn(
"flex items-start gap-3 rounded-md transition-colors",
isCollapsed && "cursor-pointer hover:bg-muted/50",
)}
onClick={isCollapsed ? () => setIsCollapsed(false) : undefined}
onKeyDown={isCollapsed ? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setIsCollapsed(false);
}
} : undefined}
role={isCollapsed ? 'button' : undefined}
tabIndex={isCollapsed ? 0 : undefined}
aria-expanded={isCollapsed ? false : undefined}
>
{/* Avatar-style icon */}
<div className={cn(
"w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 shadow-sm",
iconTone,
)}>
{isCancellation ? (
<CalendarX className="w-5 h-5" />
) : (
<Calendar className="w-5 h-5" />
)}
</div>
{/* Content */}
{showDetails && (
<div className="px-4 py-3 space-y-2.5">
<div className="lg:flex lg:gap-6">
{/* Left: Event info */}
<div className="lg:flex-1 space-y-2.5 min-w-0">
{/* Event title */}
{summary?.title && (
<div className="flex items-start justify-between gap-2">
<h3 className={cn(
"text-base font-semibold leading-snug",
isCancellation ? "line-through text-muted-foreground" : "text-foreground"
)}>
{summary.title}
</h3>
{parsedEvent?.sequence != null && parsedEvent.sequence > 0 && (
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground flex-shrink-0 whitespace-nowrap">
{t('event_updated', { sequence: parsedEvent.sequence })}
</span>
)}
</div>
)}
{/* Event details */}
<div className="lg:flex lg:items-center lg:gap-4 lg:flex-wrap space-y-1 lg:space-y-0">
{summary?.start && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Clock className="w-3.5 h-3.5 flex-shrink-0" />
<span>
{formatDateTime(summary.start)}
{summary.end && ` ${formatDateTime(summary.end)}`}
</span>
</div>
)}
{summary?.location && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<MapPin className="w-3.5 h-3.5 flex-shrink-0" />
<span>{summary.location}</span>
</div>
)}
{summary?.organizer && (
<span className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Users className="w-3.5 h-3.5 flex-shrink-0" />
{t('organizer', { name: summary.organizer })}
</span>
)}
{summary && summary.attendeeCount > 0 && (
<span className="text-sm text-muted-foreground">{t('attendees', { count: summary.attendeeCount })}</span>
)}
{/* Content column */}
<div className="flex-1 min-w-0 space-y-2">
{/* Eyebrow + title + collapse */}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{bannerTitle}
</div>
{summary?.title && (
<h3 className={cn(
"text-sm font-semibold leading-snug break-words",
isCancellation ? "line-through text-muted-foreground" : "text-foreground",
)}>
{summary.title}
</h3>
)}
</div>
{/* Right: Info & actor messages on large screens */}
<div className="lg:flex-shrink-0 lg:text-right lg:max-w-xs mt-2.5 lg:mt-0 space-y-1">
{bannerInfo && (
<p className="text-xs text-muted-foreground leading-relaxed">{bannerInfo}</p>
)}
{actorMessage && (
<p className="text-xs text-muted-foreground">{actorMessage}</p>
)}
{actorSummary?.participationComment && (
<p className="text-xs text-muted-foreground italic">
{t('actor_note', { comment: actorSummary.participationComment })}
</p>
<div className="flex items-center gap-1.5 flex-shrink-0">
{parsedEvent?.sequence != null && parsedEvent.sequence > 0 && (
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground whitespace-nowrap">
{t('event_updated', { sequence: parsedEvent.sequence })}
</span>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsCollapsed((prev) => !prev);
}}
aria-expanded={!isCollapsed}
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-0.5 transition-colors"
>
{isCollapsed ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" />}
{isCollapsed ? t('expand') : t('collapse')}
</button>
</div>
</div>
{/* Status badges */}
{(existingEvent || userIsOrganizer || (participationLabel && myParticipant) || actionFeedback || (parsedEvent?.status && parsedEvent.status !== 'confirmed')) && (
<div className="flex items-center gap-1.5 flex-wrap">
{/* Meta rows */}
{showDetails && summary && (summary.start || summary.location || summary.attendeeCount > 0) && (
<div className="flex flex-col gap-1 text-sm text-muted-foreground sm:flex-row sm:flex-wrap sm:items-center sm:gap-x-4 sm:gap-y-1">
{summary.start && (
<span className="flex items-center gap-1.5 min-w-0">
<Clock className="w-3.5 h-3.5 flex-shrink-0" />
<span className="truncate">
{formatDateTime(summary.start)}
{summary.end && ` ${formatDateTime(summary.end)}`}
</span>
</span>
)}
{summary.location && (
<span className="flex items-center gap-1.5 min-w-0">
<MapPin className="w-3.5 h-3.5 flex-shrink-0" />
<span className="truncate">{summary.location}</span>
</span>
)}
{summary.attendeeCount > 0 && (
<span className="text-muted-foreground/80">{t('attendees', { count: summary.attendeeCount })}</span>
)}
</div>
)}
{/* Organizer row (clickable, left-aligned) */}
{showDetails && summary?.organizer && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground min-w-0">
<Users className="w-3.5 h-3.5 flex-shrink-0" />
<span className="flex-shrink-0">{t('organizer_label')}</span>
{summary.organizerEmail ? (
<RecipientPopover
name={summary.organizer}
email={summary.organizerEmail}
className="text-sm truncate"
/>
) : (
<span className="truncate text-foreground">{summary.organizer}</span>
)}
</div>
)}
{/* Status pills */}
{showDetails && hasStatusPills && (
<div className="flex flex-wrap items-center gap-1.5">
{parsedEvent?.status && parsedEvent.status !== 'confirmed' && (
<span className={cn(
"rounded-full px-2 py-0.5 text-[11px] font-medium",
parsedEvent.status === 'cancelled'
? "bg-destructive/15 text-destructive"
: "bg-warning/15 text-warning"
: "bg-warning/15 text-warning",
)}>
{t(`event_status_${parsedEvent.status}`)}
</span>
@@ -862,21 +913,45 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
{participationLabel && myParticipant && (
<span className={cn(
'rounded-full px-2 py-0.5 text-[11px] font-medium',
getParticipationTone(currentRsvp)
getParticipationTone(currentRsvp),
)}>
{t('your_response', { status: participationLabel })}
</span>
)}
{actionFeedback && (
{actionNotice && (
<span className="rounded-full bg-success/15 px-2 py-0.5 text-[11px] font-medium text-success">
{actionFeedback}
{actionNotice}
</span>
)}
</div>
)}
{/* Info / actor messages */}
{showDetails && (bannerInfo || actorMessage || actorSummary?.participationComment) && (
<div className="space-y-0.5 text-xs text-muted-foreground">
{bannerInfo && <p className="leading-relaxed">{bannerInfo}</p>}
{actorMessage && <p>{actorMessage}</p>}
{actorSummary?.participationComment && (
<p className="italic">{t('actor_note', { comment: actorSummary.participationComment })}</p>
)}
</div>
)}
{/* Trust warning */}
{showDetails && trustMessage && trustAssessment && (
<div className={cn(
'flex items-start gap-2 text-sm rounded-md px-3 py-2 border',
trustAssessment.level === 'warning'
? 'bg-destructive/10 text-destructive border-destructive/30'
: 'bg-warning/10 text-warning border-warning/30',
)}>
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<span className="flex-1">{trustMessage}</span>
</div>
)}
{/* Proposed changes */}
{proposedChanges.length > 0 && (
{showDetails && proposedChanges.length > 0 && (
<div className="rounded-md border border-border bg-muted/30 px-3 py-2.5 text-xs">
<div className="font-medium text-foreground mb-1.5">{t('proposed_changes')}</div>
<div className="space-y-1.5">
@@ -890,156 +965,154 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
</div>
)}
{/* Trust warning */}
{trustMessage && trustAssessment && (
<div className={cn(
'flex items-start gap-1.5 text-xs rounded-md px-3 py-2',
trustAssessment.level === 'warning'
? 'bg-destructive/10 text-destructive border border-destructive/20'
: 'bg-warning/10 text-warning border border-warning/20'
)}>
<AlertCircle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
<span>{trustMessage}</span>
</div>
)}
{/* Action error */}
{actionError && (
<div className="flex items-start gap-1.5 text-xs text-destructive rounded-md px-3 py-2 bg-destructive/10 border border-destructive/20">
<AlertCircle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
<span>{actionError}</span>
{showDetails && actionError && (
<div className="flex items-start gap-2 text-sm rounded-md px-3 py-2 bg-destructive/10 text-destructive border border-destructive/30">
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<span className="flex-1">{actionError}</span>
</div>
)}
</div>
)}
{/* Actions */}
{showDetails && (
<div className="px-4 py-2.5 border-t border-border bg-muted/20 flex items-center gap-2 flex-wrap">
{canRespond && (
<>
<button
onClick={() => handleRsvp('accepted')}
disabled={isProcessing}
aria-pressed={currentRsvp === 'accepted'}
className={cn(
"inline-flex items-center gap-1.5 text-sm font-medium px-3 py-1.5 rounded-md transition-colors min-h-[36px] disabled:opacity-50 border",
currentRsvp === 'accepted'
? "bg-success/15 text-success border-success/20"
: "text-muted-foreground hover:text-success border-border hover:border-success/30 hover:bg-success/10"
)}
>
<Check className="w-3.5 h-3.5" />
{t('accept')}
</button>
<button
onClick={() => handleRsvp('tentative')}
disabled={isProcessing}
aria-pressed={currentRsvp === 'tentative'}
className={cn(
"inline-flex items-center gap-1.5 text-sm font-medium px-3 py-1.5 rounded-md transition-colors min-h-[36px] disabled:opacity-50 border",
currentRsvp === 'tentative'
? "bg-warning/15 text-warning border-warning/20"
: "text-muted-foreground hover:text-warning border-border hover:border-warning/30 hover:bg-warning/10"
)}
>
<HelpCircle className="w-3.5 h-3.5" />
{t('maybe')}
</button>
<button
onClick={() => handleRsvp('declined')}
disabled={isProcessing}
aria-pressed={currentRsvp === 'declined'}
className={cn(
"inline-flex items-center gap-1.5 text-sm font-medium px-3 py-1.5 rounded-md transition-colors min-h-[36px] disabled:opacity-50 border",
currentRsvp === 'declined'
? "bg-destructive/15 text-destructive border-destructive/20"
: "text-muted-foreground hover:text-destructive border-border hover:border-destructive/30 hover:bg-destructive/10"
)}
>
<X className="w-3.5 h-3.5" />
{t('decline')}
</button>
{/* Actions */}
{showActionsRow && (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
{canRespond && (
<>
<button
onClick={() => handleRsvp('accepted')}
disabled={isProcessing}
aria-pressed={currentRsvp === 'accepted'}
className={cn(
"inline-flex items-center gap-1.5 text-sm font-medium px-3 py-1.5 rounded-md transition-colors min-h-[36px] disabled:opacity-50 border",
currentRsvp === 'accepted'
? "bg-success/15 text-success border-success/30"
: "text-muted-foreground hover:text-success border-border hover:border-success/30 hover:bg-success/10",
)}
>
<Check className="w-3.5 h-3.5" />
{t('accept')}
</button>
<button
onClick={() => handleRsvp('tentative')}
disabled={isProcessing}
aria-pressed={currentRsvp === 'tentative'}
className={cn(
"inline-flex items-center gap-1.5 text-sm font-medium px-3 py-1.5 rounded-md transition-colors min-h-[36px] disabled:opacity-50 border",
currentRsvp === 'tentative'
? "bg-warning/15 text-warning border-warning/30"
: "text-muted-foreground hover:text-warning border-border hover:border-warning/30 hover:bg-warning/10",
)}
>
<HelpCircle className="w-3.5 h-3.5" />
{t('maybe')}
</button>
<button
onClick={() => handleRsvp('declined')}
disabled={isProcessing}
aria-pressed={currentRsvp === 'declined'}
className={cn(
"inline-flex items-center gap-1.5 text-sm font-medium px-3 py-1.5 rounded-md transition-colors min-h-[36px] disabled:opacity-50 border",
currentRsvp === 'declined'
? "bg-destructive/15 text-destructive border-destructive/30"
: "text-muted-foreground hover:text-destructive border-border hover:border-destructive/30 hover:bg-destructive/10",
)}
>
<X className="w-3.5 h-3.5" />
{t('decline')}
</button>
<div className="w-px h-5 bg-border mx-1" />
</>
)}
<div className="w-px h-5 bg-border" />
</>
)}
{supportsCalendar && !existingEvent && allowsImport && !isResponseOnly && !isCancellation && (
<>
<button
ref={pickerTriggerRef}
onClick={() => {
if (calendars.length <= 1) {
handleImport();
return;
}
if (showCalendarPicker) {
setShowCalendarPicker(false);
return;
}
if (pickerTriggerRef.current) {
const rect = pickerTriggerRef.current.getBoundingClientRect();
setPickerPosition({ top: rect.bottom + 4, left: rect.left });
}
setShowCalendarPicker(true);
}}
disabled={isProcessing}
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground px-3 py-1.5 rounded-md border border-border hover:bg-muted transition-colors min-h-[36px] disabled:opacity-50"
>
<CalendarCheck className="w-3.5 h-3.5" />
{t('add_to_calendar')}
{calendars.length > 1 && <ChevronDown className="w-3 h-3" />}
</button>
{supportsCalendar && !existingEvent && allowsImport && !isResponseOnly && !isCancellation && (
<div className="relative">
{showCalendarPicker && calendars.length > 1 && pickerPosition && typeof document !== 'undefined' && createPortal(
<div
className="fixed w-52 bg-background rounded-lg shadow-lg border border-border z-50 py-1"
style={{ top: pickerPosition.top, left: pickerPosition.left }}
>
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
{t('select_calendar')}
</div>
{calendars.map((cal) => (
<button
key={cal.id}
onClick={() => {
setShowCalendarPicker(false);
handleImport(cal.id);
}}
className="w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2"
>
<span
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: sanitizeColor(cal.color) }}
/>
<span className="truncate text-foreground">{cal.name}</span>
</button>
))}
</div>,
document.body,
)}
</>
)}
{canApplyProposal && (
<button
onClick={() => {
if (calendars.length <= 1) {
handleImport();
} else {
setShowCalendarPicker(!showCalendarPicker);
}
}}
onClick={handleApplyProposal}
disabled={isProcessing}
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground px-3 py-1.5 rounded-md border border-border hover:bg-muted transition-colors min-h-[36px] disabled:opacity-50"
>
<CalendarCheck className="w-3.5 h-3.5" />
{t('add_to_calendar')}
{calendars.length > 1 && <ChevronDown className="w-3 h-3" />}
{t('apply_proposal')}
</button>
)}
{showCalendarPicker && calendars.length > 1 && (
<div className="absolute left-0 top-full mt-1 w-52 bg-background rounded-lg shadow-lg border border-border z-10 py-1">
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
{t('select_calendar')}
</div>
{calendars.map((cal) => (
<button
key={cal.id}
onClick={() => {
setShowCalendarPicker(false);
handleImport(cal.id);
}}
className="w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2"
>
<span
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: sanitizeColor(cal.color) }}
/>
<span className="truncate text-foreground">{cal.name}</span>
</button>
))}
</div>
)}
</div>
)}
{supportsCalendar && (existingEvent || parsedEvent) && (
<button
onClick={handleViewInCalendar}
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground px-3 py-1.5 rounded-md hover:bg-muted transition-colors min-h-[36px]"
>
<Calendar className="w-3.5 h-3.5" />
{viewActionLabel}
<ArrowRight className="w-3 h-3" />
</button>
)}
{canApplyProposal && (
<button
onClick={handleApplyProposal}
disabled={isProcessing}
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground px-3 py-1.5 rounded-md border border-border hover:bg-muted transition-colors min-h-[36px] disabled:opacity-50"
>
<CalendarCheck className="w-3.5 h-3.5" />
{t('apply_proposal')}
</button>
)}
{!supportsCalendar && (
<span className="text-xs text-muted-foreground italic">{t('no_calendar')}</span>
)}
{supportsCalendar && (existingEvent || parsedEvent) && (
<button
onClick={handleViewInCalendar}
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground px-3 py-1.5 rounded-md hover:bg-muted transition-colors min-h-[36px]"
>
<Calendar className="w-3.5 h-3.5" />
{viewActionLabel}
<ArrowRight className="w-3 h-3" />
</button>
)}
{!supportsCalendar && (
<span className="text-xs text-muted-foreground italic">{t('no_calendar')}</span>
)}
{isProcessing && (
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground ml-auto" />
{isProcessing && (
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground ml-auto" />
)}
</div>
)}
</div>
)}
</div>
);
}
+22 -1
View File
@@ -997,7 +997,7 @@ export function EmailComposer({
return '';
};
// RFC 5322 §3.6.4 threading only continues the chain on a reply, not a forward.
// RFC 5322 §3.6.4 threading - only continues the chain on a reply, not a forward.
const threadingHeaders = (mode === 'reply' || mode === 'replyAll')
? computeReplyThreadingHeaders(replyTo)
: null;
@@ -1015,6 +1015,26 @@ export function EmailComposer({
try {
const effectiveSendAt = await getEffectiveSendAt(sendAt);
// Let plugins veto the send (external-mail warning, mistyped-domain
// guards, etc.). Returning false from any handler aborts before either
// the S/MIME or standard JMAP path runs.
const sendablePreview: OutgoingEmail = {
to: toAddresses,
cc: ccAddresses,
bcc: bccAddresses,
subject,
htmlBody: finalHtmlBody || '',
textBody: finalBody,
identityId: currentIdentity?.id || '',
fromEmail,
attachments: attachments
.filter(att => att.blobId && !att.uploading && !att.error)
.map(a => ({ name: a.name, type: a.type || 'application/octet-stream', size: a.size })),
inReplyTo: threadingHeaders?.inReplyTo?.[0],
};
const sendAllowed = await emailHooks.onBeforeEmailSend.intercept(sendablePreview);
if (!sendAllowed) return;
// S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail
if ((smimeSign_ || smimeEncrypt_) && client && currentIdentity?.id) {
// 1. Resolve S/MIME key
@@ -1159,6 +1179,7 @@ export function EmailComposer({
htmlBody: finalHtmlBody || '',
textBody: finalBody,
identityId: currentIdentity?.id || '',
fromEmail,
attachments: uploadedAttachments.map(a => ({ name: a.name, type: a.type, size: a.size })),
inReplyTo: threadingHeaders?.inReplyTo?.[0],
};
+3
View File
@@ -6,6 +6,7 @@ import type { HoverAction } from "@/stores/settings-store";
import { cn } from "@/lib/utils";
import { Trash2, Star, Mail, MailOpen, Archive, Tag, ShieldAlert } from "lucide-react";
import { useTranslations } from "next-intl";
import { useIsMobile } from "@/hooks/use-media-query";
interface EmailHoverActionsProps {
email: Email;
@@ -76,11 +77,13 @@ export function EmailHoverActions({
const hoverActionsMode = useSettingsStore((state) => state.hoverActionsMode);
const hoverActionsCorner = useSettingsStore((state) => state.hoverActionsCorner);
const t = useTranslations("settings.email_behavior.hover_actions");
const isMobile = useIsMobile();
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const hoverBackgroundClassName = backgroundClassName;
if (isMobile) return null;
if (hoverActions.length === 0) return null;
const handleAction = (e: React.MouseEvent, action: HoverAction) => {
+254 -223
View File
@@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils";
import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers";
import { emailToReadView } from "@/lib/plugin-projection";
import {
Reply,
ReplyAll,
@@ -2327,8 +2328,12 @@ export function EmailViewer({
htmlContent = email.bodyValues[email.htmlBody[0].partId].value;
// Prefer textBody when HTML is auto-generated minimal wrapper (no rich formatting).
// Server-generated HTML from text/plain emails often lacks <br> tags, collapsing newlines.
const hasTextBody = email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId];
if (hasTextBody && htmlContent) {
// Per RFC 8621, an HTML-only email exposes the same partId in both htmlBody and textBody —
// in that case there is no real plain-text alternative, so always render the HTML.
const textPartId = email.textBody?.[0]?.partId;
const htmlPartId = email.htmlBody[0].partId;
const hasDistinctTextBody = !!textPartId && textPartId !== htmlPartId && !!email.bodyValues[textPartId];
if (hasDistinctTextBody && htmlContent) {
useHtmlVersion = hasMeaningfulHtmlBody(htmlContent);
} else {
useHtmlVersion = !!htmlContent;
@@ -2728,8 +2733,19 @@ export function EmailViewer({
const colorScheme = isDark && emailHasNativeDarkMode ? 'light dark' : 'light';
// Bare HTML emails (no <style>) tend to be plain prose without their own
// layout give them the same padding as plain-text mails (.email-content-text).
const bodyPadding = effectiveEmailContent.hasStyleTag ? '0' : '1rem 1.25rem';
// layout - give them the same padding as plain-text mails (.email-content-text).
// Word/Outlook HTML emails ship a <style> block but put their gutter in
// @page margins (print-only), so they need a fallback body padding too.
const isWordHtml = /class=["']?(?:Mso|WordSection)|<o:p[\s>/]|urn:schemas-microsoft-com:office:office/i.test(effectiveEmailContent.html);
const bodyPadding = (effectiveEmailContent.hasStyleTag && !isWordHtml) ? '0' : '1rem 1.25rem';
// Word emails rely on empty <p class=MsoNormal>&nbsp;</p> spacers for vertical
// rhythm. With our default line-height: 1.6 these stack into oversized gaps;
// tighten to match how Outlook/Gmail render the same source.
const wordHtmlCSS = isWordHtml ? `
body { line-height: 1.15; }
p.MsoNormal, li.MsoNormal, div.MsoNormal { margin: 0 0 6px; }
` : '';
return `<!DOCTYPE html>
<html style="color-scheme: ${colorScheme};"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
@@ -2740,6 +2756,7 @@ export function EmailViewer({
table { max-width: 100% !important; table-layout: auto; overflow-wrap: break-word; }
td, th { word-break: break-word; }
pre { white-space: pre-wrap; word-wrap: break-word; }
${wordHtmlCSS}
${darkModeCSS}
</style></head><body>${effectiveEmailContent.html}</body></html>`;
}, [effectiveEmailContent.html, effectiveEmailContent.isHtml, isDark, emailHasNativeDarkMode]);
@@ -2790,7 +2807,7 @@ export function EmailViewer({
}, []);
// Whenever permission is granted (allow toggled, or sender becomes trusted),
// restore blocked content in the existing iframe no srcDoc rebuild.
// restore blocked content in the existing iframe - no srcDoc rebuild.
const senderEmailLower = email?.from?.[0]?.email?.toLowerCase();
const senderIsTrustedNow = senderEmailLower
? isSenderTrusted(senderEmailLower) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmailLower))
@@ -2802,7 +2819,7 @@ export function EmailViewer({
}, [allowExternalContent, senderIsTrustedNow, hasBlockedContent, restoreBlockedContent]);
// Tracks the last rendered body height so the loading skeleton can hold
// the same size avoids the body shrink/expand flash when switching emails.
// the same size - avoids the body shrink/expand flash when switching emails.
const lastBodyHeightRef = useRef<number>(300);
// True while the new email's body is still being fetched. Catches the
@@ -4080,7 +4097,7 @@ export function EmailViewer({
};
const fullDate = (iso?: string) => iso
? formatDateTime(iso, timeFormat, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', second: '2-digit', timeZoneName: 'short' })
: '';
: '-';
const auth = email.authenticationResults;
const totalAttachmentSize = effectiveAttachments.reduce((s, a) => s + (a.size || 0), 0);
const topMimeType = email.bodyStructure?.type;
@@ -4477,6 +4494,229 @@ export function EmailViewer({
</div>
</div>
{/* Mobile/Tablet Sender Info - scrolls with content */}
<div className="lg:hidden bg-background border-b border-border px-4" style={{ paddingBlock: 'var(--density-header-py)' }}>
<div className="flex items-start" style={{ gap: 'var(--density-item-gap)' }}>
<button
onClick={() => sender?.email && handleViewContactSidebar(null, sender.email)}
className="cursor-pointer group flex-shrink-0"
title={sender?.email || undefined}
>
<Avatar
name={sender?.name}
email={sender?.email}
size="lg"
className="shadow-sm w-10 h-10 group-hover:ring-2 group-hover:ring-primary/30 transition-all"
/>
</button>
<div className="flex-1 min-w-0">
{/* Row 1: Sender name + badges */}
<div className="flex items-center gap-1.5 flex-wrap">
<button
onClick={() => sender?.email && handleViewContactSidebar(null, sender.email)}
className="text-sm font-semibold text-foreground hover:text-primary hover:underline transition-colors cursor-pointer text-left"
>
{sender?.name || sender?.email || t('unknown_sender')}
</button>
<EmailIdentityBadge email={email} identities={identities} />
{shouldShowUnsubBanner && listHeaders?.listUnsubscribe && (
<UnsubscribeBanner
listUnsubscribe={listHeaders.listUnsubscribe}
senderEmail={email?.from?.[0]?.email || ''}
onDismiss={() => {
const messageId = email?.messageId || '';
const newSet = new Set(dismissedUnsubBanners).add(messageId);
setDismissedUnsubBanners(newSet);
localStorage.setItem('dismissed-unsub-banners', JSON.stringify([...newSet]));
}}
/>
)}
</div>
{/* Email address under name */}
{sender?.email && sender?.name && (
<div className="text-xs text-muted-foreground mt-0.5 truncate">{sender.email}</div>
)}
{/* Row 2: Recipients */}
<div className="mt-0.5 flex items-center gap-1 text-sm text-muted-foreground flex-wrap">
{email.to && email.to.length > 0 && (
<>
<span> {t('recipient_to_prefix')}</span>
{renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar)}
</>
)}
{email.cc && email.cc.length > 0 && (
<>
<span className="text-muted-foreground/50">|</span>
<span>CC:</span>
{renderClickableRecipients(email.cc, currentUserEmail, t, handleViewContactSidebar)}
{email.cc.length > 2 && (
<span>+{email.cc.length - 2}</span>
)}
</>
)}
</div>
</div>
{/* Date/time + size on the right (mobile) */}
<div className="sm:hidden flex-shrink-0 text-right ml-2">
<span className="text-xs text-muted-foreground whitespace-nowrap">
{formatDateTime(email.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })}
</span>
{email.size > 0 && (
<div className="text-xs text-muted-foreground/60">
{formatFileSize(email.size)}
</div>
)}
</div>
</div>
</div>
{/* S/MIME Status Banner */}
{smimeStatus && (
<div className="border-b border-border bg-muted/30">
<div className="px-6 py-1.5">
<SmimeStatusBanner
status={smimeStatus}
onUnlockKey={smimeUnlockTargetId ? openSmimeUnlockDialog : undefined}
/>
</div>
</div>
)}
{/* Scheduled Banner */}
{isScheduled && (
<div className="border-b border-border bg-primary/10">
<div className="max-w-4xl mx-auto px-6 py-2.5 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2 text-primary">
<CalendarClock className="w-4 h-4" />
<span className="text-sm font-medium">
{t('scheduled_banner', { date: email.scheduledSendAt ? formatDateTime(email.scheduledSendAt, timeFormat) : '' })}
</span>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={onCancelScheduled}>{t('cancel_scheduled_send')}</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
const sendAt = promptForRescheduleSendAt();
if (sendAt) onRescheduleScheduled?.(sendAt);
}}
>
{t('reschedule_send')}
</Button>
<Button size="sm" variant="outline" onClick={onCancelScheduledForEdit}>
{email.isSmimeScheduled ? t('cancel_and_compose_again') : t('cancel_and_edit')}
</Button>
</div>
</div>
</div>
)}
{/* Draft Banner */}
{isDraft && (
<div className="border-b border-border bg-warning/10">
<div className="max-w-4xl mx-auto px-6 py-2.5 flex items-center justify-between">
<div className="flex items-center gap-2 text-warning">
<File className="w-4 h-4" />
<span className="text-sm font-medium">{t('draft_banner')}</span>
</div>
{onEditDraft && (
<Button
size="sm"
variant="outline"
onClick={() => onEditDraft()}
className="gap-1.5"
>
<EditIcon className="w-3.5 h-3.5" />
{t('edit_draft')}
</Button>
)}
</div>
</div>
)}
<SmimePassphraseDialog
isOpen={smimeUnlockDialogOpen}
onClose={() => {
setSmimeUnlockDialogOpen(false);
setSmimeUnlockError(null);
}}
onSubmit={handleSmimeUnlockSubmit}
title={tSmime('unlock_key')}
description={tSmime('unlock_key_desc')}
error={smimeUnlockError}
/>
{/* Unified Notification Banner - External Content + Calendar Invitation */}
{((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') ||
hasCalendarInvitation) && (
<div className="border-b border-border bg-muted/30 isolate">
<div className="px-6 py-1.5">
<div className="flex flex-col gap-3 isolate">
{/* External Content Controls */}
{hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && (
<div className="flex items-start gap-3 py-1">
<div className="w-10 h-10 rounded-full bg-info/15 text-info flex items-center justify-center flex-shrink-0 shadow-sm">
<Image className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0 space-y-2">
<div>
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
External Content
</div>
<div className="text-sm font-medium text-foreground break-words">
{t('external_content_warning')}
</div>
</div>
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
{externalContentPolicy === 'ask' && (
<button
onClick={() => setAllowExternalContent(true)}
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground px-3 py-1.5 rounded-md border border-border hover:bg-muted transition-colors min-h-[36px]"
>
<Image className="w-3.5 h-3.5" />
{t('load_external_content')}
</button>
)}
{email.from?.[0]?.email && (
<button
onClick={() => {
const senderEmail = email.from?.[0]?.email;
if (senderEmail) {
if (trustedSendersAddressBook && client) {
addToTrustedSendersBook(client, senderEmail).catch(console.error);
} else {
addTrustedSender(senderEmail);
}
setAllowExternalContent(true);
}
}}
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground px-3 py-1.5 rounded-md border border-border hover:bg-muted transition-colors min-h-[36px]"
>
<ShieldCheck className="w-3.5 h-3.5" />
{t('trust_sender')}
</button>
)}
</div>
</div>
</div>
)}
{/* Calendar Invitation Banner */}
{hasCalendarInvitation && (
<div className="py-1">
<CalendarInvitationBanner email={email} />
</div>
)}
</div>
</div>
</div>
)}
<PluginSlot name="email-banner" extraProps={{ email: emailToReadView(email) }} />
{/* === ATTACHMENTS below header (below-header mode, desktop only) === */}
{attachmentPosition === 'below-header' && effectiveAttachments.length > 0 && (
<div className="hidden lg:block bg-background border-b border-border px-4 lg:px-6 py-2">
@@ -4768,215 +5008,8 @@ export function EmailViewer({
</div>
)}
{/* Mobile/Tablet Sender Info - scrolls with content */}
<div className="lg:hidden bg-background border-b border-border px-4" style={{ paddingBlock: 'var(--density-header-py)' }}>
<div className="flex items-start" style={{ gap: 'var(--density-item-gap)' }}>
<button
onClick={() => sender?.email && handleViewContactSidebar(null, sender.email)}
className="cursor-pointer group flex-shrink-0"
title={sender?.email || undefined}
>
<Avatar
name={sender?.name}
email={sender?.email}
size="lg"
className="shadow-sm w-10 h-10 group-hover:ring-2 group-hover:ring-primary/30 transition-all"
/>
</button>
<div className="flex-1 min-w-0">
{/* Row 1: Sender name + badges */}
<div className="flex items-center gap-1.5 flex-wrap">
<button
onClick={() => sender?.email && handleViewContactSidebar(null, sender.email)}
className="text-sm font-semibold text-foreground hover:text-primary hover:underline transition-colors cursor-pointer text-left"
>
{sender?.name || sender?.email || t('unknown_sender')}
</button>
<EmailIdentityBadge email={email} identities={identities} />
{shouldShowUnsubBanner && listHeaders?.listUnsubscribe && (
<UnsubscribeBanner
listUnsubscribe={listHeaders.listUnsubscribe}
senderEmail={email?.from?.[0]?.email || ''}
onDismiss={() => {
const messageId = email?.messageId || '';
const newSet = new Set(dismissedUnsubBanners).add(messageId);
setDismissedUnsubBanners(newSet);
localStorage.setItem('dismissed-unsub-banners', JSON.stringify([...newSet]));
}}
/>
)}
</div>
{/* Email address under name */}
{sender?.email && sender?.name && (
<div className="text-xs text-muted-foreground mt-0.5 truncate">{sender.email}</div>
)}
{/* Row 2: Recipients */}
<div className="mt-0.5 flex items-center gap-1 text-sm text-muted-foreground flex-wrap">
{email.to && email.to.length > 0 && (
<>
<span> {t('recipient_to_prefix')}</span>
{renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar)}
</>
)}
{email.cc && email.cc.length > 0 && (
<>
<span className="text-muted-foreground/50">|</span>
<span>CC:</span>
{renderClickableRecipients(email.cc, currentUserEmail, t, handleViewContactSidebar)}
{email.cc.length > 2 && (
<span>+{email.cc.length - 2}</span>
)}
</>
)}
</div>
</div>
{/* Date/time + size on the right (mobile) */}
<div className="sm:hidden flex-shrink-0 text-right ml-2">
<span className="text-xs text-muted-foreground whitespace-nowrap">
{formatDateTime(email.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })}
</span>
{email.size > 0 && (
<div className="text-xs text-muted-foreground/60">
{formatFileSize(email.size)}
</div>
)}
</div>
</div>
</div>
{/* S/MIME Status Banner */}
{smimeStatus && (
<div className="border-b border-border bg-muted/30">
<div className="max-w-4xl mx-auto px-6 py-1.5">
<SmimeStatusBanner
status={smimeStatus}
onUnlockKey={smimeUnlockTargetId ? openSmimeUnlockDialog : undefined}
/>
</div>
</div>
)}
{/* Scheduled Banner */}
{isScheduled && (
<div className="border-b border-border bg-primary/10">
<div className="max-w-4xl mx-auto px-6 py-2.5 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2 text-primary">
<CalendarClock className="w-4 h-4" />
<span className="text-sm font-medium">
{t('scheduled_banner', { date: email.scheduledSendAt ? formatDateTime(email.scheduledSendAt, timeFormat) : '' })}
</span>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={onCancelScheduled}>{t('cancel_scheduled_send')}</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
const sendAt = promptForRescheduleSendAt();
if (sendAt) onRescheduleScheduled?.(sendAt);
}}
>
{t('reschedule_send')}
</Button>
<Button size="sm" variant="outline" onClick={onCancelScheduledForEdit}>
{email.isSmimeScheduled ? t('cancel_and_compose_again') : t('cancel_and_edit')}
</Button>
</div>
</div>
</div>
)}
{/* Draft Banner */}
{isDraft && (
<div className="border-b border-border bg-warning/10">
<div className="max-w-4xl mx-auto px-6 py-2.5 flex items-center justify-between">
<div className="flex items-center gap-2 text-warning">
<File className="w-4 h-4" />
<span className="text-sm font-medium">{t('draft_banner')}</span>
</div>
{onEditDraft && (
<Button
size="sm"
variant="outline"
onClick={() => onEditDraft()}
className="gap-1.5"
>
<EditIcon className="w-3.5 h-3.5" />
{t('edit_draft')}
</Button>
)}
</div>
</div>
)}
<SmimePassphraseDialog
isOpen={smimeUnlockDialogOpen}
onClose={() => {
setSmimeUnlockDialogOpen(false);
setSmimeUnlockError(null);
}}
onSubmit={handleSmimeUnlockSubmit}
title={tSmime('unlock_key')}
description={tSmime('unlock_key_desc')}
error={smimeUnlockError}
/>
{/* Unified Notification Banner - External Content + Calendar Invitation */}
{((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') ||
hasCalendarInvitation) && (
<div className="border-b border-border bg-muted/30 isolate">
<div className="max-w-6xl mx-auto px-6 py-1.5">
<div className="flex flex-col gap-3 isolate">
{/* External Content Controls */}
{hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && (
<div className="flex items-center gap-3 flex-wrap md:justify-center rounded-md px-3 py-1 bg-muted/50 dark:bg-muted/30">
{externalContentPolicy === 'ask' && (
<button
onClick={() => setAllowExternalContent(true)}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground bg-transparent hover:bg-transparent transition-colors min-h-[44px] md:min-h-0"
>
<Image className="w-3.5 h-3.5" />
{t('load_external_content')}
</button>
)}
{email.from?.[0]?.email && (
<button
onClick={() => {
const senderEmail = email.from?.[0]?.email;
if (senderEmail) {
if (trustedSendersAddressBook && client) {
addToTrustedSendersBook(client, senderEmail).catch(console.error);
} else {
addTrustedSender(senderEmail);
}
setAllowExternalContent(true);
}
}}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground bg-transparent hover:bg-transparent transition-colors min-h-[44px] md:min-h-0"
>
{t('trust_sender')}
</button>
)}
</div>
)}
{/* Calendar Invitation Banner */}
{hasCalendarInvitation && (
<div className="py-1">
<CalendarInvitationBanner email={email} />
</div>
)}
</div>
</div>
</div>
)}
<div>
<PluginSlot name="email-banner" extraProps={{ email }} />
{/* Email Body */}
<div className={cn(
"email-content-wrapper overflow-x-auto",
@@ -5021,18 +5054,17 @@ export function EmailViewer({
<PluginSlot name="email-footer" />
{/* Quick Reply Section - hidden for drafts and while loading a new email */}
{!isDraft && !isScheduled && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (<div className={cn(
"mt-6 mx-6 mb-6 bg-background rounded-lg shadow-sm border transition-all",
isQuickReplyFocused || quickReplyText ? "border-primary" : "border-border"
)}>
<div className="p-4">
<div className="flex items-start gap-3">
{!isDraft && !isScheduled && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (<div className="bg-background border-t border-border px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
<div className="flex items-start" style={{ gap: 'var(--density-item-gap)' }}>
<div className="flex-shrink-0">
<Avatar
name={currentUserName || "You"}
email={currentUserEmail || ""}
size="sm"
size="lg"
className="shadow-sm w-10 h-10"
/>
<div className="flex-1 space-y-3">
</div>
<div className="flex-1 min-w-0 space-y-3">
<textarea
value={quickReplyText}
onChange={(e) => setQuickReplyText(e.target.value)}
@@ -5112,7 +5144,6 @@ export function EmailViewer({
</div>
</div>
)}
</div>
</div>
</div>
</div>)}
+2
View File
@@ -139,6 +139,8 @@ export function RichTextEditor({
extensions: [
StarterKit.configure({
heading: { levels: [1, 2] },
link: false,
underline: false,
}),
Underline,
Link.configure({
+45 -35
View File
@@ -12,13 +12,22 @@ interface SmimeStatusBannerProps {
className?: string;
}
type SmimeVariant = 'success' | 'warning' | 'error' | 'info';
const variantTone: Record<SmimeVariant, string> = {
success: 'bg-success/15 text-success',
warning: 'bg-warning/15 text-warning',
error: 'bg-destructive/15 text-destructive',
info: 'bg-info/15 text-info',
};
export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatusBannerProps) {
const t = useTranslations('smime');
const items: Array<{
icon: React.ReactNode;
text: string;
variant: 'success' | 'warning' | 'error' | 'info';
variant: SmimeVariant;
}> = [];
// Encryption status
@@ -26,26 +35,26 @@ export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatu
if (status.decryptionError) {
if (status.decryptionError === 'locked') {
items.push({
icon: <Lock className="w-4 h-4" />,
icon: <Lock className="w-5 h-5" />,
text: t('unlock_key_desc'),
variant: 'warning',
});
} else if (status.decryptionError === 'no-key') {
items.push({
icon: <Lock className="w-4 h-4" />,
icon: <Lock className="w-5 h-5" />,
text: t('status_encrypted_no_key'),
variant: 'warning',
});
} else {
items.push({
icon: <ShieldX className="w-4 h-4" />,
icon: <ShieldX className="w-5 h-5" />,
text: t('status_encrypted_failed'),
variant: 'error',
});
}
} else {
items.push({
icon: <LockOpen className="w-4 h-4" />,
icon: <LockOpen className="w-5 h-5" />,
text: t('status_encrypted_ok'),
variant: 'success',
});
@@ -57,26 +66,26 @@ export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatu
if (status.signatureValid === true) {
if (status.selfSigned) {
items.push({
icon: <AlertTriangle className="w-4 h-4" />,
icon: <AlertTriangle className="w-5 h-5" />,
text: t('status_signed_self_signed'),
variant: 'warning',
});
} else if (status.signerEmailMatch === false) {
items.push({
icon: <AlertTriangle className="w-4 h-4" />,
icon: <AlertTriangle className="w-5 h-5" />,
text: t('status_signed_mismatch'),
variant: 'warning',
});
} else {
items.push({
icon: <ShieldCheck className="w-4 h-4" />,
icon: <ShieldCheck className="w-5 h-5" />,
text: t('status_signed_valid'),
variant: 'success',
});
}
} else if (status.signatureValid === false) {
items.push({
icon: <ShieldAlert className="w-4 h-4" />,
icon: <ShieldAlert className="w-5 h-5" />,
text: status.signatureError || t('status_signed_invalid'),
variant: 'error',
});
@@ -86,7 +95,7 @@ export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatu
// Unsupported S/MIME
if (status.unsupportedReason) {
items.push({
icon: <Info className="w-4 h-4" />,
icon: <Info className="w-5 h-5" />,
text: t('status_unsupported'),
variant: 'info',
});
@@ -94,33 +103,34 @@ export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatu
if (items.length === 0) return null;
const variantStyles = {
success: 'bg-success/10 text-success border-success/30',
warning: 'bg-warning/10 text-warning border-warning/30',
error: 'bg-destructive/10 text-destructive border-destructive/30',
info: 'bg-info/10 text-info border-info/30',
};
return (
<div className={cn("flex flex-col gap-1.5 py-1", className)}>
<div className={cn("flex flex-col gap-3 py-1", className)}>
{items.map((item, i) => (
<div
key={i}
className={cn(
"flex items-center gap-2 px-3 py-1.5 rounded-md text-sm border",
variantStyles[item.variant],
)}
>
{item.icon}
<span className="flex-1">{item.text}</span>
{item.variant === 'warning' && status.decryptionError === 'locked' && onUnlockKey && (
<button
onClick={onUnlockKey}
className="text-xs font-medium underline hover:no-underline"
>
{t('unlock_key')}
</button>
)}
<div key={i} className="flex items-start gap-3">
<div className={cn(
"w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 shadow-sm",
variantTone[item.variant],
)}>
{item.icon}
</div>
<div className="flex-1 min-w-0 flex items-center justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
S/MIME
</div>
<div className="text-sm font-medium text-foreground break-words">
{item.text}
</div>
</div>
{item.variant === 'warning' && status.decryptionError === 'locked' && onUnlockKey && (
<button
onClick={onUnlockKey}
className="text-xs font-medium underline hover:no-underline flex-shrink-0"
>
{t('unlock_key')}
</button>
)}
</div>
</div>
))}
</div>
@@ -331,8 +331,12 @@ function EmailCard({
htmlContent = email.bodyValues[email.htmlBody[0].partId].value;
// Prefer textBody when HTML is auto-generated minimal wrapper (no rich formatting).
// Server-generated HTML from text/plain emails often lacks <br> tags, collapsing newlines.
const hasTextBody = email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId];
if (hasTextBody && htmlContent) {
// Per RFC 8621, an HTML-only email exposes the same partId in both htmlBody and textBody —
// in that case there is no real plain-text alternative, so always render the HTML.
const textPartId = email.textBody?.[0]?.partId;
const htmlPartId = email.htmlBody[0].partId;
const hasDistinctTextBody = !!textPartId && textPartId !== htmlPartId && !!email.bodyValues[textPartId];
if (hasDistinctTextBody && htmlContent) {
useHtmlVersion = hasMeaningfulHtmlBody(htmlContent);
} else {
useHtmlVersion = !!htmlContent;