feat: calendar invitations RSVP, trust assessment, file preview

This commit is contained in:
Linus Rath
2026-03-16 22:00:11 +01:00
parent cde1d61d02
commit 0f5d030d5f
32 changed files with 21143 additions and 279 deletions
+2 -2
View File
@@ -452,8 +452,8 @@ export default function FilesPage() {
<FilePreviewModal <FilePreviewModal
name={previewFile} name={previewFile}
onClose={() => setPreviewFile(null)} onClose={() => setPreviewFile(null)}
onDownload={handleDownload} onDownload={() => handleDownload(previewFile)}
getFileContent={getFileContent} getFileContent={() => getFileContent(previewFile)}
/> />
)} )}
+38
View File
@@ -36,6 +36,8 @@ import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils";
import { WelcomeBanner } from "@/components/ui/welcome-banner"; import { WelcomeBanner } from "@/components/ui/welcome-banner";
import { NavigationRail } from "@/components/layout/navigation-rail"; import { NavigationRail } from "@/components/layout/navigation-rail";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { isFilePreviewable } from "@/lib/file-preview";
import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square } from "lucide-react"; import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square } from "lucide-react";
import { ResizeHandle } from "@/components/layout/resize-handle"; import { ResizeHandle } from "@/components/layout/resize-handle";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -59,6 +61,7 @@ export default function Home() {
const [conversationThread, setConversationThread] = useState<ThreadGroup | null>(null); const [conversationThread, setConversationThread] = useState<ThreadGroup | null>(null);
const [conversationEmails, setConversationEmails] = useState<Email[]>([]); const [conversationEmails, setConversationEmails] = useState<Email[]>([]);
const [isLoadingConversation, setIsLoadingConversation] = useState(false); const [isLoadingConversation, setIsLoadingConversation] = useState(false);
const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string } | null>(null);
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null); const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost } = useAuthStore(); const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost } = useAuthStore();
const { identities } = useIdentityStore(); const { identities } = useIdentityStore();
@@ -746,12 +749,38 @@ export default function Home() {
if (!client) return; if (!client) return;
try { try {
const { mailAttachmentAction } = useSettingsStore.getState();
if (mailAttachmentAction === 'preview' && isFilePreviewable(name, type)) {
setPreviewAttachment({ blobId, name, type });
return;
}
await client.downloadBlob(blobId, name, type); await client.downloadBlob(blobId, name, type);
} catch (error) { } catch (error) {
console.error("Failed to download attachment:", error); console.error("Failed to download attachment:", error);
} }
}; };
const handlePreviewAttachmentDownload = useCallback(async () => {
if (!client || !previewAttachment) return;
await client.downloadBlob(previewAttachment.blobId, previewAttachment.name, previewAttachment.type);
}, [client, previewAttachment]);
const getPreviewAttachmentContent = useCallback(async () => {
if (!client || !previewAttachment) {
throw new Error('No attachment selected');
}
const blob = await client.fetchBlob(previewAttachment.blobId, previewAttachment.name, previewAttachment.type);
return {
blob,
contentType: previewAttachment.type || blob.type || 'application/octet-stream',
};
}, [client, previewAttachment]);
const handleQuickReply = async (body: string) => { const handleQuickReply = async (body: string) => {
if (!client || !selectedEmail) return; if (!client || !selectedEmail) return;
@@ -1498,6 +1527,15 @@ export default function Home() {
onClose={() => setShowShortcutsModal(false)} onClose={() => setShowShortcutsModal(false)}
/> />
{previewAttachment && (
<FilePreviewModal
name={previewAttachment.name}
onClose={() => setPreviewAttachment(null)}
onDownload={handlePreviewAttachmentDownload}
getFileContent={getPreviewAttachmentContent}
/>
)}
{/* Screen reader live region for dynamic status announcements */} {/* Screen reader live region for dynamic status announcements */}
<div className="sr-only" aria-live="polite" aria-atomic="true" id="sr-status" /> <div className="sr-only" aria-live="polite" aria-atomic="true" id="sr-status" />
@@ -0,0 +1,592 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CalendarInvitationBanner } from '../calendar-invitation-banner';
import type { Email } from '@/lib/jmap/types';
const mocks = vi.hoisted(() => {
const pushMock = vi.fn();
const clientMock = {
parseCalendarEvents: vi.fn(),
getCalendarsAccountId: vi.fn(() => 'calendar-account'),
getCalendarEvent: vi.fn(),
queryCalendarEvents: vi.fn(async () => []),
};
const authState = {
client: clientMock,
primaryIdentity: { email: 'user@example.com' },
};
const settingsState = {
calendarInvitationParsingEnabled: true,
};
const calendarState = {
calendars: [{ id: 'cal-1', name: 'Primary', color: '#2563eb', isDefault: true }],
supportsCalendar: true,
importEvents: vi.fn(),
rsvpEvent: vi.fn(),
updateEvent: vi.fn(),
events: [] as Array<Record<string, unknown>>,
setSelectedDate: vi.fn(),
};
const useCalendarStoreMock = ((selector?: (state: typeof calendarState) => unknown) => (
typeof selector === 'function' ? selector(calendarState) : calendarState
)) as {
(selector?: (state: typeof calendarState) => unknown): unknown;
getState: () => typeof calendarState;
setState: (updater: Partial<typeof calendarState> | ((state: typeof calendarState) => Partial<typeof calendarState> | typeof calendarState)) => void;
};
useCalendarStoreMock.getState = () => calendarState;
useCalendarStoreMock.setState = (updater) => {
const nextState = typeof updater === 'function' ? updater(calendarState) : updater;
Object.assign(calendarState, nextState);
};
return {
pushMock,
clientMock,
authState,
settingsState,
calendarState,
useCalendarStoreMock,
};
});
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, values?: Record<string, string>) => {
const strings: Record<string, string> = {
loading: 'Loading event details…',
title: 'Calendar Invitation',
organizer: 'Organized by {name}',
attendees: 'attendees',
add_to_calendar: 'Add to calendar',
added: 'Added to calendar',
rsvp_sent: 'Response sent',
already_in_calendar: 'Already in your calendar',
your_response: 'Your response: {status}',
response_accepted: 'Accepted',
response_needed: 'Needs response',
actor_response_info: '{name} responded {status}.',
actor_sent_info: 'Sent by {name}.',
actor_counter_info: '{name} proposed changes to this event.',
actor_refresh_info: '{name} asked for the latest event details.',
actor_declined_counter_info: '{name} declined the counter proposal.',
actor_note: 'Note: {comment}',
actor_unknown: 'Someone',
action_failed: 'Could not complete that calendar action.',
proposal_applied: 'Proposed changes applied.',
proposed_changes: 'Proposed changes',
change_title: 'Title',
change_time: 'Time',
change_location: 'Location',
change_description: 'Description',
change_empty: 'None',
change_from_to: '{before} -> {after}',
apply_proposal: 'Apply proposed changes',
review_proposal: 'Review proposal',
review_request: 'Review request',
view_in_calendar: 'View in calendar',
organizer_role: 'You organize this event',
accept: 'Accept',
maybe: 'Maybe',
decline: 'Decline',
select_calendar: 'Select calendar',
};
let message = strings[key] ?? key;
if (values) {
for (const [name, value] of Object.entries(values)) {
message = message.replace(`{${name}}`, String(value));
}
}
return message;
},
useFormatter: () => ({
dateTime: (date: Date) => date.toISOString(),
}),
}));
vi.mock('@/i18n/navigation', () => ({
useRouter: () => ({ push: mocks.pushMock }),
}));
vi.mock('@/stores/auth-store', () => ({
useAuthStore: (selector: (state: typeof mocks.authState) => unknown) => selector(mocks.authState),
}));
vi.mock('@/stores/settings-store', () => ({
useSettingsStore: (selector: (state: typeof mocks.settingsState) => unknown) => selector(mocks.settingsState),
}));
vi.mock('@/stores/calendar-store', () => ({
useCalendarStore: mocks.useCalendarStoreMock,
}));
function makeEmail(overrides: Partial<Email> = {}): Email {
return {
id: 'email-1',
threadId: 'thread-1',
mailboxIds: { inbox: true },
keywords: {},
size: 512,
receivedAt: '2026-03-16T10:00:00Z',
hasAttachment: true,
attachments: [
{
partId: '1',
blobId: 'blob-1',
size: 256,
type: 'text/calendar; method=REQUEST',
name: 'invite.ics',
},
],
...overrides,
};
}
describe('CalendarInvitationBanner', () => {
beforeEach(() => {
mocks.pushMock.mockReset();
mocks.settingsState.calendarInvitationParsingEnabled = true;
mocks.clientMock.parseCalendarEvents.mockReset();
mocks.clientMock.getCalendarEvent.mockReset();
mocks.clientMock.getCalendarEvent.mockResolvedValue(null);
mocks.clientMock.queryCalendarEvents.mockReset();
mocks.clientMock.queryCalendarEvents.mockResolvedValue([]);
mocks.calendarState.importEvents.mockReset();
mocks.calendarState.rsvpEvent.mockReset();
mocks.calendarState.updateEvent.mockReset();
mocks.calendarState.setSelectedDate.mockReset();
mocks.calendarState.events = [];
});
it('does not parse invitations when invitation parsing is disabled', () => {
mocks.settingsState.calendarInvitationParsingEnabled = false;
const { container } = render(<CalendarInvitationBanner email={makeEmail()} />);
expect(container.firstChild).toBeNull();
expect(mocks.clientMock.parseCalendarEvents).not.toHaveBeenCalled();
});
it('shows existing event status, current response, and view-in-calendar action', async () => {
mocks.calendarState.events = [
{
id: 'event-1',
uid: 'uid-1',
start: '2026-03-20T09:00:00Z',
participants: {
attendee: {
'@type': 'Participant',
email: 'user@example.com',
name: 'User',
roles: { attendee: true },
participationStatus: 'accepted',
},
},
},
];
mocks.clientMock.parseCalendarEvents.mockResolvedValue([
{
uid: 'uid-1',
title: 'Team Sync',
start: '2026-03-20T09:00:00Z',
participants: {
attendee: {
'@type': 'Participant',
email: 'user@example.com',
name: 'User',
roles: { attendee: true },
participationStatus: 'accepted',
},
},
},
]);
render(<CalendarInvitationBanner email={makeEmail()} />);
await waitFor(() => {
expect(screen.getByText('Already in your calendar')).toBeInTheDocument();
});
expect(screen.getByText('Your response: Accepted')).toBeInTheDocument();
fireEvent.click(screen.getByText('View in calendar'));
expect(mocks.calendarState.setSelectedDate).toHaveBeenCalledTimes(1);
expect(mocks.pushMock).toHaveBeenCalledWith('/calendar');
});
it('keeps the invitation summary visible after importing', async () => {
mocks.calendarState.importEvents.mockResolvedValue(1);
mocks.clientMock.parseCalendarEvents.mockResolvedValue([
{
uid: 'uid-2',
title: 'Planning Session',
start: '2026-03-22T13:00:00Z',
participants: {
attendee: {
'@type': 'Participant',
email: 'user@example.com',
name: 'User',
roles: { attendee: true },
participationStatus: 'needs-action',
},
},
},
]);
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-2' })} />);
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Planning Session' })).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Add to calendar'));
await waitFor(() => {
expect(screen.getByText('Added to calendar')).toBeInTheDocument();
});
expect(screen.getByRole('heading', { name: 'Planning Session' })).toBeInTheDocument();
});
it('does not show RSVP actions when the current user is the organizer', async () => {
mocks.clientMock.parseCalendarEvents.mockResolvedValue([
{
uid: 'uid-3',
title: 'Organizer Review',
start: '2026-03-25T11:00:00Z',
participants: {
organizer: {
'@type': 'Participant',
email: 'user@example.com',
name: 'User',
roles: { owner: true },
participationStatus: 'accepted',
},
attendee: {
'@type': 'Participant',
email: 'alice@example.com',
name: 'Alice',
roles: { attendee: true },
participationStatus: 'needs-action',
},
},
},
]);
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-3' })} />);
await waitFor(() => {
expect(screen.getByText('You organize this event')).toBeInTheDocument();
});
expect(screen.queryByText('Accept')).not.toBeInTheDocument();
expect(screen.queryByText('Maybe')).not.toBeInTheDocument();
expect(screen.queryByText('Decline')).not.toBeInTheDocument();
});
it('keeps the banner visible when an import action fails', async () => {
mocks.calendarState.importEvents.mockResolvedValue(0);
mocks.clientMock.parseCalendarEvents.mockResolvedValue([
{
uid: 'uid-4',
title: 'Failed Import Event',
start: '2026-03-26T15:00:00Z',
participants: {
attendee: {
'@type': 'Participant',
email: 'user@example.com',
name: 'User',
roles: { attendee: true },
participationStatus: 'needs-action',
},
},
},
]);
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-4' })} />);
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Failed Import Event' })).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Add to calendar'));
await waitFor(() => {
expect(screen.getByText('Could not complete that calendar action.')).toBeInTheDocument();
});
expect(screen.getByRole('heading', { name: 'Failed Import Event' })).toBeInTheDocument();
});
it('hydrates sparse existing events before sending RSVP', async () => {
mocks.calendarState.events = [
{
id: 'event-6',
uid: 'uid-6',
start: '2026-03-28T09:00:00Z',
},
];
mocks.clientMock.parseCalendarEvents.mockResolvedValue([
{
uid: 'uid-6',
title: 'Sparse Event',
start: '2026-03-28T09:00:00Z',
participants: {
parsedAttendee: {
'@type': 'Participant',
email: 'user@example.com',
name: 'User',
roles: { required: true },
participationStatus: 'needs-action',
},
},
},
]);
mocks.clientMock.getCalendarEvent.mockResolvedValue({
id: 'event-6',
uid: 'uid-6',
start: '2026-03-28T09:00:00Z',
participants: {
canonicalAttendee: {
'@type': 'Participant',
email: 'user@example.com',
name: 'User',
roles: { attendee: true },
participationStatus: 'needs-action',
},
},
});
mocks.calendarState.rsvpEvent.mockResolvedValue(undefined);
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-6' })} />);
await waitFor(() => {
expect(screen.getByText('Accept')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Accept'));
await waitFor(() => {
expect(mocks.clientMock.getCalendarEvent).toHaveBeenCalledWith('event-6');
});
expect(mocks.calendarState.rsvpEvent).toHaveBeenCalledWith(
mocks.clientMock,
'event-6',
'canonicalAttendee',
'accepted',
null,
);
});
it('falls back to organizerCalendarAddress when replyTo is missing', async () => {
mocks.calendarState.events = [
{
id: 'event-7',
uid: 'uid-7',
start: '2026-03-29T09:00:00Z',
participants: {
attendee: {
'@type': 'Participant',
calendarAddress: 'mailto:user@example.com',
name: 'User',
roles: { attendee: true },
participationStatus: 'needs-action',
},
},
},
];
mocks.clientMock.parseCalendarEvents.mockResolvedValue([
{
uid: 'uid-7',
title: 'Invite Without Reply-To',
start: '2026-03-29T09:00:00Z',
organizerCalendarAddress: 'mailto:organizer@example.com',
participants: {
attendee: {
'@type': 'Participant',
calendarAddress: 'mailto:user@example.com',
name: 'User',
roles: { required: true },
participationStatus: 'needs-action',
},
},
},
]);
mocks.calendarState.rsvpEvent.mockResolvedValue(undefined);
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-7' })} />);
await waitFor(() => {
expect(screen.getByText('Accept')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Accept'));
await waitFor(() => {
expect(mocks.calendarState.rsvpEvent).toHaveBeenCalledWith(
mocks.clientMock,
'event-7',
'attendee',
'accepted',
{ imip: 'mailto:organizer@example.com' },
);
});
});
it('repairs sparse existing events with parsed participants before RSVPing', async () => {
mocks.calendarState.events = [
{
id: 'event-8',
uid: 'uid-8',
start: '2026-03-30T09:00:00Z',
},
];
mocks.clientMock.getCalendarEvent.mockResolvedValue({
id: 'event-8',
uid: 'uid-8',
start: '2026-03-30T09:00:00Z',
});
mocks.clientMock.parseCalendarEvents.mockResolvedValue([
{
uid: 'uid-8',
title: 'Sparse Stored Event',
start: '2026-03-30T09:00:00Z',
organizerCalendarAddress: 'mailto:organizer@example.com',
participants: {
organizer: {
'@type': 'Participant',
calendarAddress: 'mailto:organizer@example.com',
name: 'Organizer',
roles: { required: true },
participationStatus: 'accepted',
},
attendee: {
'@type': 'Participant',
calendarAddress: 'mailto:user@example.com',
name: 'User',
roles: { required: true },
participationStatus: 'needs-action',
},
},
},
]);
mocks.calendarState.updateEvent.mockResolvedValue(undefined);
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-8' })} />);
await waitFor(() => {
expect(screen.getByText('Accept')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Accept'));
await waitFor(() => {
expect(mocks.calendarState.updateEvent).toHaveBeenCalledWith(
mocks.clientMock,
'event-8',
expect.objectContaining({
replyTo: { imip: 'mailto:organizer@example.com' },
participants: expect.objectContaining({
attendee: expect.objectContaining({
participationStatus: 'accepted',
}),
}),
}),
true,
);
});
expect(mocks.calendarState.rsvpEvent).not.toHaveBeenCalled();
});
it('shows counter changes and lets organizers apply the proposal', async () => {
mocks.calendarState.updateEvent = vi.fn().mockResolvedValue(undefined);
mocks.calendarState.events = [
{
id: 'event-5',
uid: 'uid-5',
title: 'Original Sync',
start: '2026-03-27T09:00:00Z',
duration: 'PT1H',
locations: {
room: { '@type': 'Location', name: 'Room A' },
},
participants: {
organizer: {
'@type': 'Participant',
email: 'user@example.com',
name: 'User',
roles: { owner: true },
participationStatus: 'accepted',
},
attendee: {
'@type': 'Participant',
email: 'alice@example.com',
name: 'Alice',
roles: { attendee: true },
participationStatus: 'tentative',
participationComment: 'Could we move this later?',
},
},
},
];
mocks.clientMock.parseCalendarEvents.mockResolvedValue([
{
uid: 'uid-5',
title: 'Original Sync',
start: '2026-03-27T10:00:00Z',
duration: 'PT1H',
locations: {
room: { '@type': 'Location', name: 'Room B' },
},
participants: {
organizer: {
'@type': 'Participant',
email: 'user@example.com',
name: 'User',
roles: { owner: true },
participationStatus: 'accepted',
},
attendee: {
'@type': 'Participant',
email: 'alice@example.com',
name: 'Alice',
roles: { attendee: true },
participationStatus: 'tentative',
participationComment: 'Could we move this later?',
},
},
},
]);
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-5', attachments: [{ partId: '1', blobId: 'blob-5', size: 256, type: 'text/calendar; method=COUNTER', name: 'counter.ics' }] })} />);
await waitFor(() => {
expect(screen.getByText('Proposed changes')).toBeInTheDocument();
});
expect(screen.getByText(/Location/)).toBeInTheDocument();
expect(screen.getByText('Review proposal')).toBeInTheDocument();
fireEvent.click(screen.getByText('Apply proposed changes'));
await waitFor(() => {
expect(mocks.calendarState.updateEvent).toHaveBeenCalledTimes(1);
});
expect(screen.getByText('Proposed changes applied.')).toBeInTheDocument();
});
});
File diff suppressed because it is too large Load Diff
+17 -4
View File
@@ -33,6 +33,7 @@ import {
FileAudio, FileAudio,
FileArchive, FileArchive,
File, File,
Eye,
Shield, Shield,
Image, Image,
Tag, Tag,
@@ -76,6 +77,7 @@ import { UnsubscribeBanner } from "./unsubscribe-banner";
import { CalendarInvitationBanner } from "./calendar-invitation-banner"; import { CalendarInvitationBanner } from "./calendar-invitation-banner";
import { findCalendarAttachment } from "@/lib/calendar-invitation"; import { findCalendarAttachment } from "@/lib/calendar-invitation";
import { RecipientPopover } from "./recipient-popover"; import { RecipientPopover } from "./recipient-popover";
import { isFilePreviewable } from "@/lib/file-preview";
interface EmailViewerProps { interface EmailViewerProps {
email: Email | null; email: Email | null;
@@ -483,12 +485,15 @@ export function EmailViewer({
const t = useTranslations('email_viewer'); const t = useTranslations('email_viewer');
const tNotifications = useTranslations('notifications'); const tNotifications = useTranslations('notifications');
const tCommon = useTranslations('common'); const tCommon = useTranslations('common');
const tFiles = useTranslations('files');
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy); const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction);
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender); const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted); const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted);
const emailKeywords = useSettingsStore((state) => state.emailKeywords); const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition); const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels); const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled);
// Detect if current mailbox is Junk folder // Detect if current mailbox is Junk folder
const isInJunkFolder = currentMailboxRole === 'junk'; const isInJunkFolder = currentMailboxRole === 'junk';
@@ -1139,7 +1144,9 @@ export function EmailViewer({
listHeaders?.listUnsubscribe?.preferred && listHeaders?.listUnsubscribe?.preferred &&
!dismissedUnsubBanners.has(email?.messageId || ''); !dismissedUnsubBanners.has(email?.messageId || '');
const hasCalendarInvitation = email ? !!findCalendarAttachment(email) : false; const hasCalendarInvitation = email
? calendarInvitationParsingEnabled && !!findCalendarAttachment(email)
: false;
// Show loading skeleton while email is being fetched // Show loading skeleton while email is being fetched
if (isLoading && !email) { if (isLoading && !email) {
@@ -2798,11 +2805,13 @@ export function EmailViewer({
<div className="flex items-start gap-2 flex-wrap"> <div className="flex items-start gap-2 flex-wrap">
{email.attachments.map((attachment, i) => { {email.attachments.map((attachment, i) => {
const FileIcon = getFileIcon(attachment.name, attachment.type); const FileIcon = getFileIcon(attachment.name, attachment.type);
const isPreviewable = isFilePreviewable(attachment.name, attachment.type);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return ( return (
<button <button
key={i} key={i}
className="inline-flex items-center gap-2 px-3 py-2 bg-muted/60 hover:bg-accent rounded-lg transition-colors group border border-border/50" className="inline-flex items-center gap-2 px-3 py-2 bg-muted/60 hover:bg-accent rounded-lg transition-colors group border border-border/50"
title={`${t('download')} ${attachment.name} (${formatFileSize(attachment.size)})`} title={`${opensPreview ? tFiles('preview') : t('download')} ${attachment.name} (${formatFileSize(attachment.size)})`}
onClick={() => { onClick={() => {
if (attachment.blobId && onDownloadAttachment) { if (attachment.blobId && onDownloadAttachment) {
onDownloadAttachment(attachment.blobId, attachment.name || 'download', attachment.type); onDownloadAttachment(attachment.blobId, attachment.name || 'download', attachment.type);
@@ -2818,7 +2827,11 @@ export function EmailViewer({
{formatFileSize(attachment.size)} {formatFileSize(attachment.size)}
</span> </span>
</div> </div>
<Download className="w-3.5 h-3.5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" /> {opensPreview ? (
<Eye className="w-3.5 h-3.5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" />
) : (
<Download className="w-3.5 h-3.5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" />
)}
</button> </button>
); );
})} })}
@@ -2931,7 +2944,7 @@ export function EmailViewer({
{/* Calendar Invitation Banner */} {/* Calendar Invitation Banner */}
{hasCalendarInvitation && ( {hasCalendarInvitation && (
<div className="rounded-md px-3 py-1 bg-amber-50/50 dark:bg-amber-950/20"> <div className="py-1">
<CalendarInvitationBanner email={email} /> <CalendarInvitationBanner email={email} />
</div> </div>
)} )}
+11 -1
View File
@@ -26,10 +26,12 @@ import {
FileAudio, FileAudio,
FileArchive, FileArchive,
File, File,
Eye,
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { isFilePreviewable } from "@/lib/file-preview";
interface ThreadConversationViewProps { interface ThreadConversationViewProps {
thread: ThreadGroup; thread: ThreadGroup;
@@ -222,6 +224,7 @@ function EmailCard({
const t = useTranslations(); const t = useTranslations();
const resolvedTheme = useThemeStore((state) => state.resolvedTheme); const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const density = useSettingsStore((state) => state.density); const density = useSettingsStore((state) => state.density);
const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction);
const sender = email.from?.[0]; const sender = email.from?.[0];
const isUnread = !email.keywords?.$seen; const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged; const isStarred = email.keywords?.$flagged;
@@ -526,6 +529,8 @@ function EmailCard({
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{email.attachments.map((attachment, idx) => { {email.attachments.map((attachment, idx) => {
const Icon = getFileIcon(attachment.name, attachment.type); const Icon = getFileIcon(attachment.name, attachment.type);
const isPreviewable = isFilePreviewable(attachment.name, attachment.type);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return ( return (
<button <button
key={idx} key={idx}
@@ -533,6 +538,7 @@ function EmailCard({
e.stopPropagation(); e.stopPropagation();
onDownloadAttachment?.(attachment.blobId, attachment.name || 'attachment', attachment.type); onDownloadAttachment?.(attachment.blobId, attachment.name || 'attachment', attachment.type);
}} }}
title={opensPreview ? t('files.preview') : t('email_viewer.download')}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-muted hover:bg-muted/80 transition-colors text-sm" className="flex items-center gap-2 px-3 py-2 rounded-lg bg-muted hover:bg-muted/80 transition-colors text-sm"
> >
<Icon className="w-4 h-4 text-muted-foreground" /> <Icon className="w-4 h-4 text-muted-foreground" />
@@ -540,7 +546,11 @@ function EmailCard({
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">
{formatFileSize(attachment.size)} {formatFileSize(attachment.size)}
</span> </span>
<Download className="w-4 h-4 text-muted-foreground" /> {opensPreview ? (
<Eye className="w-4 h-4 text-muted-foreground" />
) : (
<Download className="w-4 h-4 text-muted-foreground" />
)}
</button> </button>
); );
})} })}
+42 -30
View File
@@ -4,32 +4,13 @@ import { useState, useEffect } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { X, Download, Loader2 } from "lucide-react"; import { X, Download, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { getFilePreviewKind } from "@/lib/file-preview";
interface FilePreviewModalProps { interface FilePreviewModalProps {
name: string; name: string;
onClose: () => void; onClose: () => void;
onDownload: (name: string) => Promise<void>; onDownload: () => Promise<void> | void;
getFileContent: (name: string) => Promise<{ blob: Blob; contentType: string }>; getFileContent: () => Promise<{ blob: Blob; contentType: string }>;
}
const TEXT_EXTENSIONS = new Set([
"txt", "md", "markdown", "json", "xml", "html", "htm", "css", "js", "ts",
"jsx", "tsx", "py", "rb", "java", "c", "cpp", "h", "hpp", "go", "rs",
"sh", "bash", "zsh", "yaml", "yml", "toml", "ini", "cfg", "conf", "env",
"log", "csv", "sql", "graphql", "vue", "svelte", "astro", "php", "pl",
"swift", "kt", "scala", "r", "lua", "vim",
]);
function getFileType(name: string): "text" | "pdf" | "audio" | "video" | "markdown" | "unknown" {
const ext = name.split(".").pop()?.toLowerCase() || "";
const baseName = name.toLowerCase();
if (ext === "md" || ext === "markdown") return "markdown";
if (ext === "pdf") return "pdf";
if (["mp3", "wav", "ogg", "flac", "aac", "m4a", "wma", "opus"].includes(ext)) return "audio";
if (["mp4", "webm", "ogv", "mov", "avi", "mkv", "m4v"].includes(ext)) return "video";
if (TEXT_EXTENSIONS.has(ext) || ["dockerfile", "makefile", "readme", "license", "changelog"].includes(baseName)) return "text";
return "unknown";
} }
function SimpleMarkdown({ content }: { content: string }) { function SimpleMarkdown({ content }: { content: string }) {
@@ -129,24 +110,35 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
const [objectUrl, setObjectUrl] = useState<string | null>(null); const [objectUrl, setObjectUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [resolvedFileType, setResolvedFileType] = useState(() => getFilePreviewKind(name));
const fileType = getFileType(name); const fileType = resolvedFileType;
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
let revokeUrl: string | null = null;
setContent(null);
setObjectUrl(null);
setLoading(true);
setError(false);
setResolvedFileType(getFilePreviewKind(name));
async function load() { async function load() {
try { try {
const { blob, contentType } = await getFileContent(name); const { blob, contentType } = await getFileContent();
if (cancelled) return; if (cancelled) return;
if (fileType === "text" || fileType === "markdown") { const previewType = getFilePreviewKind(name, contentType || blob.type);
setResolvedFileType(previewType);
if (previewType === "text" || previewType === "markdown") {
const text = await blob.text(); const text = await blob.text();
if (!cancelled) setContent(text); if (!cancelled) setContent(text);
} else { } else {
const url = URL.createObjectURL(blob); revokeUrl = URL.createObjectURL(blob);
if (!cancelled) setObjectUrl(url); if (!cancelled) setObjectUrl(revokeUrl);
} }
} catch { } catch {
if (!cancelled) setError(true); if (!cancelled) setError(true);
@@ -159,9 +151,11 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
return () => { return () => {
cancelled = true; cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl); if (revokeUrl) {
URL.revokeObjectURL(revokeUrl);
}
}; };
}, [name]); }, [getFileContent, name]);
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
@@ -176,7 +170,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
<div className="flex items-center justify-between px-4 py-3 bg-background/90 backdrop-blur border-b border-border" onClick={(e) => e.stopPropagation()}> <div className="flex items-center justify-between px-4 py-3 bg-background/90 backdrop-blur border-b border-border" onClick={(e) => e.stopPropagation()}>
<h3 className="text-sm font-medium truncate">{name}</h3> <h3 className="text-sm font-medium truncate">{name}</h3>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => onDownload(name)}> <Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => void onDownload()}>
<Download className="w-4 h-4" /> <Download className="w-4 h-4" />
</Button> </Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onClose}> <Button variant="ghost" size="icon" className="h-8 w-8" onClick={onClose}>
@@ -208,6 +202,24 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
</div> </div>
)} )}
{!loading && !error && fileType === "image" && objectUrl && (
<img
src={objectUrl}
alt={name}
className="max-w-full max-h-full object-contain rounded-lg bg-background"
draggable={false}
/>
)}
{!loading && !error && fileType === "html" && objectUrl && (
<iframe
src={objectUrl}
sandbox=""
className="w-full max-w-5xl h-full rounded-lg bg-white"
title={name}
/>
)}
{!loading && !error && fileType === "pdf" && objectUrl && ( {!loading && !error && fileType === "pdf" && objectUrl && (
<iframe <iframe
src={objectUrl} src={objectUrl}
+18 -1
View File
@@ -11,7 +11,14 @@ export function CalendarSettings() {
const tDays = useTranslations('calendar.days'); const tDays = useTranslations('calendar.days');
const { viewMode, setViewMode } = useCalendarStore(); const { viewMode, setViewMode } = useCalendarStore();
const { timeFormat, firstDayOfWeek, calendarNotificationsEnabled, calendarNotificationSound, updateSetting } = useSettingsStore(); const {
timeFormat,
firstDayOfWeek,
calendarNotificationsEnabled,
calendarNotificationSound,
calendarInvitationParsingEnabled,
updateSetting,
} = useSettingsStore();
return ( return (
<SettingsSection title={t('title')}> <SettingsSection title={t('title')}>
@@ -71,6 +78,16 @@ export function CalendarSettings() {
/> />
</SettingItem> </SettingItem>
<SettingItem
label={t('invitation_parsing')}
description={t('invitation_parsing_desc')}
>
<ToggleSwitch
checked={calendarInvitationParsingEnabled}
onChange={(checked) => updateSetting('calendarInvitationParsingEnabled', checked)}
/>
</SettingItem>
</SettingsSection> </SettingsSection>
); );
} }
+12
View File
@@ -18,6 +18,7 @@ export function EmailSettings() {
showPreview, showPreview,
emailsPerPage, emailsPerPage,
externalContentPolicy, externalContentPolicy,
mailAttachmentAction,
trustedSenders, trustedSenders,
updateSetting, updateSetting,
} = useSettingsStore(); } = useSettingsStore();
@@ -79,6 +80,17 @@ export function EmailSettings() {
<ToggleSwitch checked={showPreview} onChange={(checked) => updateSetting('showPreview', checked)} /> <ToggleSwitch checked={showPreview} onChange={(checked) => updateSetting('showPreview', checked)} />
</SettingItem> </SettingItem>
<SettingItem label={t('attachment_click_action.label')} description={t('attachment_click_action.description')}>
<Select
value={mailAttachmentAction}
onChange={(value) => updateSetting('mailAttachmentAction', value as 'preview' | 'download')}
options={[
{ value: 'preview', label: t('attachment_click_action.preview') },
{ value: 'download', label: t('attachment_click_action.download') },
]}
/>
</SettingItem>
{/* Emails Per Page */} {/* Emails Per Page */}
<SettingItem label={t('emails_per_page.label')} description={t('emails_per_page.description')}> <SettingItem label={t('emails_per_page.label')} description={t('emails_per_page.description')}>
<Select <Select
+1
View File
@@ -40,6 +40,7 @@ function makeEvent(overrides: Partial<CalendarEvent> = {}): CalendarEvent {
categories: null, categories: null,
locale: null, locale: null,
replyTo: null, replyTo: null,
organizerCalendarAddress: null,
participants: null, participants: null,
mayInviteSelf: false, mayInviteSelf: false,
mayInviteOthers: false, mayInviteOthers: false,
+210
View File
@@ -1,7 +1,9 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { import {
findCalendarAttachment, findCalendarAttachment,
getInvitationActorSummary,
getInvitationMethod, getInvitationMethod,
getInvitationTrustAssessment,
formatEventSummary, formatEventSummary,
findParticipantByEmail, findParticipantByEmail,
} from '../calendar-invitation'; } from '../calendar-invitation';
@@ -25,6 +27,7 @@ function makeParticipant(overrides: Partial<CalendarParticipant> = {}): Calendar
'@type': 'Participant', '@type': 'Participant',
name: 'Test', name: 'Test',
email: 'test@example.com', email: 'test@example.com',
calendarAddress: null,
description: null, description: null,
sendTo: null, sendTo: null,
kind: 'individual', kind: 'individual',
@@ -107,6 +110,16 @@ describe('findCalendarAttachment', () => {
expect(findCalendarAttachment(email)).toBeNull(); expect(findCalendarAttachment(email)).toBeNull();
}); });
it('finds attachment when text/calendar includes MIME parameters', () => {
const email = makeEmail({
attachments: [
{ partId: '1', blobId: 'b6', size: 500, type: 'text/calendar; method=REQUEST; charset=UTF-8' },
],
hasAttachment: true,
});
expect(findCalendarAttachment(email)?.blobId).toBe('b6');
});
it('detects text/calendar in textBody parts', () => { it('detects text/calendar in textBody parts', () => {
const email = makeEmail({ const email = makeEmail({
textBody: [ textBody: [
@@ -118,6 +131,24 @@ describe('findCalendarAttachment', () => {
expect(result!.blobId).toBe('tb1'); expect(result!.blobId).toBe('tb1');
}); });
it('detects nested text/calendar body parts inside multipart structures', () => {
const email = makeEmail({
textBody: [
{
partId: 'root',
blobId: 'rootBlob',
size: 100,
type: 'multipart/alternative',
subParts: [
{ partId: 'plain', blobId: 'plainBlob', size: 50, type: 'text/plain' },
{ partId: 'ical', blobId: 'tbNested', size: 300, type: 'text/calendar; method=REQUEST; charset=UTF-8' },
],
},
],
});
expect(findCalendarAttachment(email)?.blobId).toBe('tbNested');
});
it('prioritizes attachments over textBody', () => { it('prioritizes attachments over textBody', () => {
const email = makeEmail({ const email = makeEmail({
attachments: [ attachments: [
@@ -137,6 +168,46 @@ describe('getInvitationMethod', () => {
expect(getInvitationMethod({ status: 'cancelled' })).toBe('cancel'); expect(getInvitationMethod({ status: 'cancelled' })).toBe('cancel');
}); });
it('detects request from MIME Content-Type method parameter', () => {
const event: Partial<CalendarEvent> = {};
const email = makeEmail({
headers: {
'Content-Type': 'text/calendar; method=REQUEST; charset=UTF-8',
},
});
expect(getInvitationMethod(event, { email })).toBe('request');
});
it('detects reply from attachment MIME method parameter', () => {
const event: Partial<CalendarEvent> = {
participants: {
att: makeParticipant({ participationStatus: 'accepted' }),
},
};
expect(getInvitationMethod(event, {
attachment: { type: 'text/calendar; method=REPLY; charset=UTF-8' },
})).toBe('reply');
});
it('detects publish, add, refresh, counter, and declinecounter from MIME parameters', () => {
const event: Partial<CalendarEvent> = {};
expect(getInvitationMethod(event, { attachment: { type: 'text/calendar; method=PUBLISH' } })).toBe('publish');
expect(getInvitationMethod(event, { attachment: { type: 'text/calendar; method=ADD' } })).toBe('add');
expect(getInvitationMethod(event, { attachment: { type: 'text/calendar; method=REFRESH' } })).toBe('refresh');
expect(getInvitationMethod(event, { attachment: { type: 'text/calendar; method=COUNTER' } })).toBe('counter');
expect(getInvitationMethod(event, { attachment: { type: 'text/calendar; method=DECLINECOUNTER' } })).toBe('declinecounter');
});
it('does not treat text/calendar without method as iMIP metadata', () => {
const event: Partial<CalendarEvent> = {};
const email = makeEmail({
headers: {
'Content-Type': 'text/calendar; charset=UTF-8',
},
});
expect(getInvitationMethod(event, { email })).toBe('unknown');
});
it('detects request when participants have organizer role', () => { it('detects request when participants have organizer role', () => {
const event: Partial<CalendarEvent> = { const event: Partial<CalendarEvent> = {
participants: { participants: {
@@ -168,6 +239,59 @@ describe('getInvitationMethod', () => {
}; };
expect(getInvitationMethod(event)).toBe('unknown'); expect(getInvitationMethod(event)).toBe('unknown');
}); });
it('infers reply when attendee status is present without an organizer', () => {
const event: Partial<CalendarEvent> = {
participants: {
att: makeParticipant({ roles: { attendee: true }, participationStatus: 'accepted' }),
},
};
expect(getInvitationMethod(event)).toBe('reply');
});
});
describe('getInvitationActorSummary', () => {
it('picks the attendee as actor for reply-like methods', () => {
const event: Partial<CalendarEvent> = {
participants: {
org: makeParticipant({ roles: { owner: true }, email: 'organizer@example.com', name: 'Organizer' }),
att: makeParticipant({
roles: { attendee: true },
email: 'alice@example.com',
name: 'Alice',
participationStatus: 'accepted',
participationComment: 'Works for me',
}),
},
};
expect(getInvitationActorSummary(event, 'reply')).toMatchObject({
role: 'attendee',
name: 'Alice',
participationStatus: 'accepted',
participationComment: 'Works for me',
});
});
it('picks the organizer as actor for declinecounter', () => {
const event: Partial<CalendarEvent> = {
participants: {
org: makeParticipant({ roles: { owner: true }, email: 'organizer@example.com', name: 'Organizer' }),
att: makeParticipant({
roles: { attendee: true },
email: 'alice@example.com',
name: 'Alice',
participationStatus: 'tentative',
}),
},
};
expect(getInvitationActorSummary(event, 'declinecounter')).toMatchObject({
role: 'organizer',
name: 'Organizer',
email: 'organizer@example.com',
});
});
}); });
describe('formatEventSummary', () => { describe('formatEventSummary', () => {
@@ -228,6 +352,92 @@ describe('formatEventSummary', () => {
}); });
}); });
describe('getInvitationTrustAssessment', () => {
it('warns when mail authentication fails', () => {
const event: Partial<CalendarEvent> = {
participants: {
org: makeParticipant({ roles: { owner: true }, email: 'organizer@example.com' }),
},
};
const result = getInvitationTrustAssessment(event, makeEmail({
from: [{ email: 'organizer@example.com' }],
authenticationResults: {
dmarc: { result: 'fail', domain: 'example.com', policy: 'reject' },
},
}), 'request');
expect(result.level).toBe('warning');
expect(result.reason).toBe('authentication_failed');
});
it('warns when sender and organizer differ without verified authentication', () => {
const event: Partial<CalendarEvent> = {
participants: {
org: makeParticipant({ roles: { owner: true }, email: 'organizer@example.com' }),
},
};
const result = getInvitationTrustAssessment(event, makeEmail({
from: [{ email: 'calendar-bot@example.net' }],
}), 'request');
expect(result.level).toBe('warning');
expect(result.reason).toBe('sender_mismatch_unverified');
});
it('shows caution when sender and organizer differ but authentication passed', () => {
const event: Partial<CalendarEvent> = {
participants: {
org: makeParticipant({ roles: { owner: true }, email: 'organizer@example.com' }),
},
};
const result = getInvitationTrustAssessment(event, makeEmail({
from: [{ email: 'assistant@example.com' }],
authenticationResults: {
dkim: { result: 'pass', domain: 'example.com', selector: 'mail' },
},
}), 'request');
expect(result.level).toBe('caution');
expect(result.reason).toBe('sender_mismatch');
});
it('shows caution when an invitation has no verified authentication', () => {
const event: Partial<CalendarEvent> = {
participants: {
org: makeParticipant({ roles: { owner: true }, email: 'organizer@example.com' }),
},
};
const result = getInvitationTrustAssessment(event, makeEmail({
from: [{ email: 'organizer@example.com' }],
}), 'request');
expect(result.level).toBe('caution');
expect(result.reason).toBe('authentication_missing');
});
it('returns trusted when sender matches organizer and authentication passed', () => {
const event: Partial<CalendarEvent> = {
participants: {
org: makeParticipant({ roles: { owner: true }, email: 'organizer@example.com' }),
},
};
const result = getInvitationTrustAssessment(event, makeEmail({
from: [{ email: 'organizer@example.com' }],
authenticationResults: {
spf: { result: 'pass', domain: 'example.com', ip: '203.0.113.5' },
},
}), 'request');
expect(result.level).toBe('trusted');
expect(result.reason).toBeNull();
});
});
describe('findParticipantByEmail', () => { describe('findParticipantByEmail', () => {
it('finds participant by direct email match', () => { it('finds participant by direct email match', () => {
const event: Partial<CalendarEvent> = { const event: Partial<CalendarEvent> = {
@@ -49,6 +49,7 @@ function makeEvent(participants: Record<string, Partial<CalendarParticipant>> |
updated: '2026-03-01T09:00:00Z', updated: '2026-03-01T09:00:00Z',
locale: null, locale: null,
replyTo: null, replyTo: null,
organizerCalendarAddress: null,
participants: participants as Record<string, CalendarParticipant> | null, participants: participants as Record<string, CalendarParticipant> | null,
mayInviteSelf: false, mayInviteSelf: false,
mayInviteOthers: false, mayInviteOthers: false,
+1
View File
@@ -47,6 +47,7 @@ function makeEvent(overrides: Partial<CalendarEvent> = {}): CalendarEvent {
categories: null, categories: null,
locale: null, locale: null,
replyTo: null, replyTo: null,
organizerCalendarAddress: null,
participants: null, participants: null,
mayInviteSelf: false, mayInviteSelf: false,
mayInviteOthers: false, mayInviteOthers: false,
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { getFilePreviewKind, isFilePreviewable } from '../file-preview';
describe('file preview detection', () => {
it('detects browser-renderable image attachments', () => {
expect(getFilePreviewKind('photo.avif', 'image/avif')).toBe('image');
expect(getFilePreviewKind('vector.svg')).toBe('image');
});
it('detects html attachments', () => {
expect(getFilePreviewKind('message.html', 'text/html; charset=utf-8')).toBe('html');
expect(getFilePreviewKind('index.htm')).toBe('html');
});
it('detects text and markdown attachments', () => {
expect(getFilePreviewKind('notes.txt', 'text/plain')).toBe('text');
expect(getFilePreviewKind('README.md', 'text/markdown')).toBe('markdown');
expect(getFilePreviewKind('payload.json', 'application/json')).toBe('text');
});
it('detects pdf, audio, and video attachments', () => {
expect(getFilePreviewKind('doc.pdf')).toBe('pdf');
expect(getFilePreviewKind('audio.m4a')).toBe('audio');
expect(getFilePreviewKind('movie.webm')).toBe('video');
});
it('rejects unsupported attachment types', () => {
expect(getFilePreviewKind('archive.zip', 'application/zip')).toBe('unsupported');
expect(isFilePreviewable('archive.zip', 'application/zip')).toBe(false);
});
});
+439 -22
View File
@@ -1,11 +1,348 @@
import type { Email, Attachment, CalendarEvent, CalendarParticipant } from '@/lib/jmap/types'; import type { Email, Attachment, CalendarEvent, CalendarParticipant, EmailBodyPart } from '@/lib/jmap/types';
export type InvitationMethod =
| 'publish'
| 'request'
| 'reply'
| 'add'
| 'cancel'
| 'refresh'
| 'counter'
| 'declinecounter'
| 'unknown';
export interface InvitationTrustAssessment {
level: 'trusted' | 'caution' | 'warning';
reason:
| 'authentication_failed'
| 'authentication_missing'
| 'sender_mismatch'
| 'sender_mismatch_unverified'
| null;
senderEmail: string | null;
organizerEmail: string | null;
}
export interface InvitationActorSummary {
name: string | null;
email: string | null;
role: 'organizer' | 'attendee';
participationStatus: CalendarParticipant['participationStatus'] | null;
participationComment: string | null;
}
const KNOWN_METHODS = new Set<InvitationMethod>([
'publish',
'request',
'reply',
'add',
'cancel',
'refresh',
'counter',
'declinecounter',
]);
function parseContentType(value?: string | null): { mimeType: string; params: Record<string, string> } {
if (!value) {
return { mimeType: '', params: {} };
}
const parts = value.split(';').map((part) => part.trim()).filter(Boolean);
const [mimeType = '', ...paramParts] = parts;
const params: Record<string, string> = {};
for (const part of paramParts) {
const separatorIndex = part.indexOf('=');
if (separatorIndex === -1) continue;
const key = part.slice(0, separatorIndex).trim().toLowerCase();
const rawValue = part.slice(separatorIndex + 1).trim();
params[key] = rawValue.replace(/^"|"$/g, '');
}
return {
mimeType: mimeType.toLowerCase(),
params,
};
}
function isCalendarMimeType(value?: string | null): boolean {
const { mimeType } = parseContentType(value);
return mimeType === 'text/calendar' || mimeType === 'application/ics' || mimeType === 'application/icalendar';
}
function normalizeInvitationMethod(value?: string | null): InvitationMethod {
if (!value) return 'unknown';
const normalized = value.trim().toLowerCase();
return KNOWN_METHODS.has(normalized as InvitationMethod)
? normalized as InvitationMethod
: 'unknown';
}
function extractMethodFromContentType(value?: string | null): InvitationMethod {
const { params } = parseContentType(value);
return normalizeInvitationMethod(params.method);
}
/**
* Extract METHOD from raw ICS/iCalendar text content.
* JMAP strips parameters from Content-Type (RFC 8621), so `text/calendar; method=REQUEST`
* becomes just `text/calendar`. This function reads the METHOD property from the raw
* VCALENDAR data as a reliable fallback.
*/
export function extractMethodFromRawIcs(rawText: string): InvitationMethod {
const match = rawText.match(/^METHOD:(\S+)/m);
return match ? normalizeInvitationMethod(match[1]) : 'unknown';
}
function getHeaderValue(headers: Email['headers'] | undefined, headerName: string): string | null {
if (!headers) return null;
const target = headerName.toLowerCase();
for (const [name, value] of Object.entries(headers)) {
if (name.toLowerCase() !== target) continue;
return Array.isArray(value) ? (value[0] ?? null) : value;
}
return null;
}
function normalizeEmailAddress(value?: string | null): string | null {
if (!value) return null;
const normalized = value.trim().replace(/^mailto:/i, '').toLowerCase();
return normalized || null;
}
function getPrimaryAddressEmail(addresses?: Array<{ email?: string | null }>): string | null {
if (!addresses) return null;
for (const address of addresses) {
const normalized = normalizeEmailAddress(address.email);
if (normalized) {
return normalized;
}
}
return null;
}
function getParticipantEmail(participant: CalendarParticipant): string | null {
const directEmail = normalizeEmailAddress(participant.email);
if (directEmail) {
return directEmail;
}
// Stalwart uses calendarAddress (mailto:...) instead of email/sendTo
if (participant.calendarAddress) {
const normalized = normalizeEmailAddress(participant.calendarAddress);
if (normalized) {
return normalized;
}
}
if (!participant.sendTo) {
return null;
}
for (const address of Object.values(participant.sendTo)) {
const normalized = normalizeEmailAddress(address);
if (normalized) {
return normalized;
}
}
return null;
}
function getParticipantName(participant: CalendarParticipant): string | null {
return participant.name || getParticipantEmail(participant);
}
function isOrganizerParticipant(participant: CalendarParticipant): boolean {
return Boolean(participant.roles?.owner || participant.roles?.chair);
}
function getParticipantSignalScore(participant: CalendarParticipant): number {
let score = 0;
if (participant.participationStatus && participant.participationStatus !== 'needs-action') {
score += 2;
}
if (participant.participationComment) {
score += 2;
}
if (participant.scheduleStatus?.length) {
score += 1;
}
return score;
}
function getOrganizerEmail(event: Partial<CalendarEvent>): string | null {
if (event.participants) {
for (const participant of Object.values(event.participants)) {
if (isOrganizerParticipant(participant)) {
return getParticipantEmail(participant);
}
}
}
// Stalwart uses organizerCalendarAddress instead of roles.owner/chair
if (event.organizerCalendarAddress) {
return normalizeEmailAddress(event.organizerCalendarAddress);
}
return null;
}
function hasVerifiedAuthentication(email?: Pick<Email, 'authenticationResults'>): boolean {
const authenticationResults = email?.authenticationResults;
return Boolean(
authenticationResults?.dmarc?.result === 'pass'
|| authenticationResults?.dkim?.result === 'pass'
|| authenticationResults?.spf?.result === 'pass'
);
}
function hasAuthenticationFailure(email?: Pick<Email, 'authenticationResults'>): boolean {
const authenticationResults = email?.authenticationResults;
return Boolean(
authenticationResults?.dmarc?.result === 'fail'
|| authenticationResults?.dkim?.result === 'fail'
|| authenticationResults?.dkim?.result === 'policy'
|| authenticationResults?.dkim?.result === 'permerror'
|| authenticationResults?.spf?.result === 'fail'
|| authenticationResults?.spf?.result === 'softfail'
|| authenticationResults?.spf?.result === 'permerror'
);
}
function findCalendarBodyPart(parts?: EmailBodyPart[]): Attachment | null {
if (!parts) return null;
for (const part of parts) {
if (isCalendarMimeType(part.type) || part.name?.toLowerCase().endsWith('.ics') || part.name?.toLowerCase().endsWith('.ical')) {
return {
partId: part.partId,
blobId: part.blobId,
size: part.size,
name: part.name || 'invite.ics',
type: part.type,
charset: part.charset,
disposition: part.disposition,
cid: part.cid,
};
}
const nested = findCalendarBodyPart(part.subParts);
if (nested) {
return nested;
}
}
return null;
}
function looksLikeReply(event: Partial<CalendarEvent>): boolean {
if (!event.participants) return false;
const participants = Object.values(event.participants);
const hasOrganizer = participants.some((participant) => isOrganizerParticipant(participant));
if (hasOrganizer) return false;
return participants.some((participant) =>
participant.roles?.attendee
&& (
participant.participationStatus !== 'needs-action'
|| !!participant.participationComment
|| !!participant.scheduleStatus?.length
)
);
}
export function getInvitationActorSummary(
event: Partial<CalendarEvent>,
method: InvitationMethod,
): InvitationActorSummary | null {
if (!event.participants) {
return null;
}
const participants = Object.values(event.participants);
let organizer = participants.find((participant) => isOrganizerParticipant(participant)) ?? null;
// Stalwart uses organizerCalendarAddress instead of roles.owner/chair
if (!organizer && event.organizerCalendarAddress) {
organizer = participants.find(
(p) => p.calendarAddress === event.organizerCalendarAddress
) ?? null;
}
const attendees = participants.filter((participant) => participant !== organizer && !isOrganizerParticipant(participant));
const respondingAttendee = [...attendees].sort((left, right) => (
getParticipantSignalScore(right) - getParticipantSignalScore(left)
))[0] ?? null;
const sourceParticipant = (() => {
switch (method) {
case 'reply':
case 'counter':
case 'refresh':
return respondingAttendee;
case 'declinecounter':
case 'request':
case 'publish':
case 'add':
case 'cancel':
return organizer ?? respondingAttendee;
default:
return respondingAttendee ?? organizer;
}
})();
if (!sourceParticipant) {
return null;
}
return {
name: getParticipantName(sourceParticipant),
email: getParticipantEmail(sourceParticipant),
role: isOrganizerParticipant(sourceParticipant) ? 'organizer' : 'attendee',
participationStatus: sourceParticipant.participationStatus ?? null,
participationComment: sourceParticipant.participationComment ?? null,
};
}
function getMethodFromEmail(email?: Pick<Email, 'headers' | 'attachments' | 'textBody' | 'htmlBody'>, attachment?: Pick<Attachment, 'type'> | null): InvitationMethod {
const explicitAttachmentMethod = extractMethodFromContentType(attachment?.type);
if (explicitAttachmentMethod !== 'unknown') {
return explicitAttachmentMethod;
}
if (email?.attachments) {
for (const item of email.attachments) {
if (!isCalendarMimeType(item.type)) continue;
const method = extractMethodFromContentType(item.type);
if (method !== 'unknown') return method;
}
}
for (const bodyPart of [findCalendarBodyPart(email?.textBody), findCalendarBodyPart(email?.htmlBody)]) {
const method = extractMethodFromContentType(bodyPart?.type);
if (method !== 'unknown') return method;
}
return extractMethodFromContentType(getHeaderValue(email?.headers, 'Content-Type'));
}
export function findCalendarAttachment(email: Email): Attachment | null { export function findCalendarAttachment(email: Email): Attachment | null {
if (email.attachments) { if (email.attachments) {
for (const att of email.attachments) { for (const att of email.attachments) {
if ( if (
att.type === 'text/calendar' || isCalendarMimeType(att.type) ||
att.type === 'application/ics' ||
att.name?.toLowerCase().endsWith('.ics') || att.name?.toLowerCase().endsWith('.ics') ||
att.name?.toLowerCase().endsWith('.ical') att.name?.toLowerCase().endsWith('.ical')
) { ) {
@@ -14,42 +351,100 @@ export function findCalendarAttachment(email: Email): Attachment | null {
} }
} }
if (email.textBody) { const inlineAttachment = findCalendarBodyPart(email.textBody) || findCalendarBodyPart(email.htmlBody);
for (const part of email.textBody) { if (inlineAttachment) return inlineAttachment;
if (part.type === 'text/calendar' && part.blobId) {
return {
partId: part.partId,
blobId: part.blobId,
size: part.size,
name: part.name || 'invite.ics',
type: 'text/calendar',
};
}
}
}
return null; return null;
} }
export function getInvitationMethod( export function getInvitationMethod(
event: Partial<CalendarEvent> event: Partial<CalendarEvent>,
): 'request' | 'reply' | 'cancel' | 'unknown' { options?: {
email?: Pick<Email, 'headers' | 'attachments' | 'textBody' | 'htmlBody'>;
attachment?: Pick<Attachment, 'type'> | null;
}
): InvitationMethod {
const explicitMethod = getMethodFromEmail(options?.email, options?.attachment);
if (explicitMethod !== 'unknown') {
return explicitMethod;
}
if (event.status === 'cancelled') { if (event.status === 'cancelled') {
return 'cancel'; return 'cancel';
} }
if (event.participants && Object.keys(event.participants).length > 0) { if (event.participants && Object.keys(event.participants).length > 0) {
const hasOrganizer = Object.values(event.participants).some( const hasOrganizer = Object.values(event.participants).some(
(p: CalendarParticipant) => p.roles?.owner || p.roles?.chair (p: CalendarParticipant) => isOrganizerParticipant(p)
); );
if (hasOrganizer) { if (hasOrganizer) {
return 'request'; return 'request';
} }
} }
if (looksLikeReply(event)) {
return 'reply';
}
return 'unknown'; return 'unknown';
} }
export function getInvitationTrustAssessment(
event: Partial<CalendarEvent>,
email?: Pick<Email, 'from' | 'replyTo' | 'authenticationResults'>,
method: InvitationMethod = getInvitationMethod(event)
): InvitationTrustAssessment {
const organizerEmail = getOrganizerEmail(event);
const senderEmail = getPrimaryAddressEmail(email?.from) || getPrimaryAddressEmail(email?.replyTo);
const verifiedAuthentication = hasVerifiedAuthentication(email);
const authenticationFailure = hasAuthenticationFailure(email);
const senderMismatch = Boolean(senderEmail && organizerEmail && senderEmail !== organizerEmail);
const expectsAuthenticatedTransport = method !== 'unknown';
if (senderMismatch && (authenticationFailure || !verifiedAuthentication)) {
return {
level: 'warning',
reason: 'sender_mismatch_unverified',
senderEmail,
organizerEmail,
};
}
if (authenticationFailure) {
return {
level: 'warning',
reason: 'authentication_failed',
senderEmail,
organizerEmail,
};
}
if (senderMismatch) {
return {
level: 'caution',
reason: 'sender_mismatch',
senderEmail,
organizerEmail,
};
}
if (expectsAuthenticatedTransport && !verifiedAuthentication) {
return {
level: 'caution',
reason: 'authentication_missing',
senderEmail,
organizerEmail,
};
}
return {
level: 'trusted',
reason: null,
senderEmail,
organizerEmail,
};
}
export interface EventSummary { export interface EventSummary {
title: string; title: string;
start: string | null; start: string | null;
@@ -76,15 +471,30 @@ export function formatEventSummary(event: Partial<CalendarEvent>): EventSummary
if (event.participants) { if (event.participants) {
for (const p of Object.values(event.participants)) { for (const p of Object.values(event.participants)) {
if (p.roles?.owner || p.roles?.chair) { if (p.roles?.owner || p.roles?.chair) {
organizer = p.name || p.email || null; organizer = p.name || getParticipantEmail(p) || null;
organizerEmail = p.email || null; organizerEmail = getParticipantEmail(p);
} }
if (p.roles?.attendee) { if (p.roles?.attendee || p.roles?.required) {
attendeeCount++; attendeeCount++;
} }
} }
} }
// Stalwart provides organizerCalendarAddress instead of roles.owner/chair
if (!organizerEmail && event.organizerCalendarAddress) {
organizerEmail = normalizeEmailAddress(event.organizerCalendarAddress);
if (!organizer && event.participants) {
// Find the participant matching the organizer address for their name
for (const p of Object.values(event.participants)) {
if (p.calendarAddress === event.organizerCalendarAddress) {
organizer = p.name || organizerEmail;
break;
}
}
}
if (!organizer) organizer = organizerEmail;
}
let end: string | null = null; let end: string | null = null;
if (event.utcEnd) { if (event.utcEnd) {
end = event.utcEnd; end = event.utcEnd;
@@ -134,6 +544,13 @@ export function findParticipantByEmail(
if (p.email?.toLowerCase() === lowerEmail) { if (p.email?.toLowerCase() === lowerEmail) {
return { id, participant: p }; return { id, participant: p };
} }
// Stalwart uses calendarAddress (mailto:...) instead of email/sendTo
if (p.calendarAddress) {
const addr = p.calendarAddress.replace('mailto:', '').toLowerCase();
if (addr === lowerEmail) {
return { id, participant: p };
}
}
if (p.sendTo) { if (p.sendTo) {
for (const addr of Object.values(p.sendTo)) { for (const addr of Object.values(p.sendTo)) {
if (addr.replace('mailto:', '').toLowerCase() === lowerEmail) { if (addr.replace('mailto:', '').toLowerCase() === lowerEmail) {
+66
View File
@@ -0,0 +1,66 @@
export type FilePreviewKind = 'image' | 'html' | 'text' | 'markdown' | 'pdf' | 'audio' | 'video' | 'unsupported';
const IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'avif', 'bmp', 'ico']);
const AUDIO_EXTENSIONS = new Set(['mp3', 'wav', 'ogg', 'm4a', 'flac', 'aac', 'opus']);
const VIDEO_EXTENSIONS = new Set(['mp4', 'webm', 'ogv', 'mov', 'm4v', 'avi', 'mkv']);
const TEXT_EXTENSIONS = new Set([
'txt', 'text', 'log', 'csv', 'json', 'xml', 'css', 'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx',
'yaml', 'yml', 'toml', 'ini', 'cfg', 'conf', 'env', 'sql', 'graphql', 'html', 'htm',
'md', 'markdown',
]);
const TEXT_MIME_TYPES = new Set([
'application/json',
'application/ld+json',
'application/xml',
'application/javascript',
'application/x-javascript',
'application/typescript',
]);
function normalizeMimeType(type?: string): string {
return type?.split(';')[0]?.trim().toLowerCase() || '';
}
function getExtension(name?: string): string {
const parts = name?.toLowerCase().split('.') || [];
return parts.length > 1 ? parts.pop() || '' : '';
}
export function getFilePreviewKind(name?: string, type?: string): FilePreviewKind {
const ext = getExtension(name);
const mimeType = normalizeMimeType(type);
if (mimeType.startsWith('image/') || IMAGE_EXTENSIONS.has(ext)) {
return 'image';
}
if (mimeType === 'text/html' || mimeType === 'application/xhtml+xml' || ext === 'html' || ext === 'htm') {
return 'html';
}
if (mimeType === 'application/pdf' || ext === 'pdf') {
return 'pdf';
}
if (mimeType.startsWith('audio/') || AUDIO_EXTENSIONS.has(ext)) {
return 'audio';
}
if (mimeType.startsWith('video/') || VIDEO_EXTENSIONS.has(ext)) {
return 'video';
}
if (ext === 'md' || ext === 'markdown') {
return 'markdown';
}
if (mimeType.startsWith('text/') || TEXT_MIME_TYPES.has(mimeType) || TEXT_EXTENSIONS.has(ext)) {
return 'text';
}
return 'unsupported';
}
export function isFilePreviewable(name?: string, type?: string): boolean {
return getFilePreviewKind(name, type) !== 'unsupported';
}
+170 -10
View File
@@ -1476,6 +1476,168 @@ export class JMAPClient {
} }
} }
/**
* Send an iMIP (RFC 6047) REPLY email to the organizer after an RSVP.
* This is needed when the server does not handle sendSchedulingMessages.
*/
async sendImipReply(opts: {
organizerEmail: string;
organizerName?: string;
attendeeEmail: string;
attendeeName?: string;
uid: string;
summary?: string;
dtStart?: string;
dtEnd?: string;
timeZone?: string;
sequence?: number;
status: 'ACCEPTED' | 'TENTATIVE' | 'DECLINED';
identityId?: string;
}): Promise<void> {
const mailboxes = await this.getMailboxes();
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
if (!sentMailbox) {
throw new Error('No sent mailbox found');
}
let finalIdentityId = opts.identityId;
if (!finalIdentityId) {
const identityResponse = await this.request([
["Identity/get", { accountId: this.accountId }, "0"]
]);
if (identityResponse.methodResponses?.[0]?.[0] === "Identity/get") {
const identities = (identityResponse.methodResponses[0][1].list || []) as { id: string; email: string }[];
const match = identities.find((id) => id.email === opts.attendeeEmail);
finalIdentityId = match?.id || identities[0]?.id || this.accountId;
} else {
finalIdentityId = this.accountId;
}
}
// Build iCalendar REPLY (RFC 5546 §3.2.3)
const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
// Format a JSCalendar date string into iCalendar format
const formatIcalDate = (dateStr: string, tz?: string): string => {
// If it's an ISO UTC string (ends with Z), convert to iCalendar UTC format
if (dateStr.endsWith('Z')) {
return dateStr.replace(/[-:]/g, '').replace(/\.\d{3}/, '');
}
// Local date-time: strip punctuation, keep as-is for TZID parameter
const basic = dateStr.replace(/[-:]/g, '').replace(/\.\d{3}/, '');
if (tz) {
return `TZID=${tz}:${basic}`;
}
return basic;
};
const lines: string[] = [
'BEGIN:VCALENDAR',
'PRODID:-//JMAP-Webmail//EN',
'VERSION:2.0',
'CALSCALE:GREGORIAN',
'METHOD:REPLY',
'BEGIN:VEVENT',
`UID:${opts.uid}`,
`DTSTAMP:${now}`,
];
if (opts.dtStart) {
const formatted = formatIcalDate(opts.dtStart, opts.timeZone);
// If TZID is included, it's a parameter on the property
if (formatted.startsWith('TZID=')) {
lines.push(`DTSTART;${formatted}`);
} else {
lines.push(`DTSTART:${formatted}`);
}
}
if (opts.dtEnd) {
const formatted = formatIcalDate(opts.dtEnd, opts.timeZone);
if (formatted.startsWith('TZID=')) {
lines.push(`DTEND;${formatted}`);
} else {
lines.push(`DTEND:${formatted}`);
}
}
if (opts.summary) {
lines.push(`SUMMARY:${opts.summary}`);
}
if (opts.sequence != null) {
lines.push(`SEQUENCE:${opts.sequence}`);
}
const orgCn = opts.organizerName ? `;CN=${opts.organizerName}` : '';
lines.push(`ORGANIZER${orgCn}:mailto:${opts.organizerEmail}`);
const attCn = opts.attendeeName ? `;CN=${opts.attendeeName}` : '';
lines.push(`ATTENDEE;PARTSTAT=${opts.status}${attCn}:mailto:${opts.attendeeEmail}`);
lines.push('END:VEVENT');
lines.push('END:VCALENDAR');
const icsContent = lines.join('\r\n') + '\r\n';
console.log('[iMIP DEBUG] Generated ICS:\n' + icsContent);
const statusLabels: Record<string, string> = {
ACCEPTED: 'Accepted',
TENTATIVE: 'Tentative',
DECLINED: 'Declined',
};
const statusLabel = statusLabels[opts.status] || opts.status;
const subject = `${statusLabel}: ${opts.summary || 'Event'}`;
console.log('[iMIP DEBUG] identityId:', finalIdentityId);
const emailId = `imip-reply-${Date.now()}`;
const emailCreate: Record<string, unknown> = {
from: [{ name: opts.attendeeName || undefined, email: opts.attendeeEmail }],
to: [{ name: opts.organizerName || undefined, email: opts.organizerEmail }],
subject,
keywords: { "$seen": true },
mailboxIds: { [sentMailbox.id]: true },
bodyStructure: {
type: 'multipart/alternative',
subParts: [
{ partId: 'text', type: 'text/plain' },
{ partId: 'cal', type: 'text/calendar; method=REPLY' },
],
},
bodyValues: {
text: { value: `${opts.attendeeName || opts.attendeeEmail} has ${statusLabel.toLowerCase()} the invitation to: ${opts.summary || 'Event'}` },
cal: { value: icsContent },
},
};
const methodCalls: JMAPMethodCall[] = [
["Email/set", {
accountId: this.accountId,
create: { [emailId]: emailCreate },
}, "0"],
["EmailSubmission/set", {
accountId: this.accountId,
create: { "sub-1": { emailId: `#${emailId}`, identityId: finalIdentityId } },
}, "1"],
];
console.log('[iMIP DEBUG] Sending JMAP request with', methodCalls.length, 'method calls');
console.log('[iMIP DEBUG] Email create payload:', JSON.stringify(emailCreate, null, 2));
const response = await this.request(methodCalls);
console.log('[iMIP DEBUG] JMAP response:', JSON.stringify(response.methodResponses, null, 2));
if (response.methodResponses) {
for (const [methodName, result] of response.methodResponses) {
if (methodName.endsWith('/error')) {
console.error('[iMIP DEBUG] method error:', methodName, result);
throw new Error(result.description || `iMIP reply failed: ${result.type}`);
}
if (result.notCreated) {
const firstError = Object.values(result.notCreated)[0] as { description?: string; type?: string };
console.error('[iMIP DEBUG] create error:', JSON.stringify(result.notCreated, null, 2));
throw new Error(firstError?.description || firstError?.type || 'Failed to send iMIP reply');
}
}
}
console.log('[iMIP DEBUG] sendImipReply completed successfully');
}
async uploadBlob(file: File): Promise<{ blobId: string; size: number; type: string }> { async uploadBlob(file: File): Promise<{ blobId: string; size: number; type: string }> {
if (!this.session) { if (!this.session) {
throw new Error('Not connected. Call connect() first.'); throw new Error('Not connected. Call connect() first.');
@@ -1541,13 +1703,17 @@ export class JMAPClient {
.replace('{type}', encodeURIComponent(type || 'application/octet-stream')); .replace('{type}', encodeURIComponent(type || 'application/octet-stream'));
} }
async fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string): Promise<string> { async fetchBlob(blobId: string, name?: string, type?: string): Promise<Blob> {
const url = this.getBlobDownloadUrl(blobId, name, type); const url = this.getBlobDownloadUrl(blobId, name, type);
const response = await this.authenticatedFetch(url, {}); const response = await this.authenticatedFetch(url, {});
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch blob: ${response.status}`); throw new Error(`Failed to fetch blob: ${response.status}`);
} }
const blob = await response.blob(); return response.blob();
}
async fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string): Promise<string> {
const blob = await this.fetchBlob(blobId, name, type);
return URL.createObjectURL(blob); return URL.createObjectURL(blob);
} }
@@ -2282,6 +2448,7 @@ export class JMAPClient {
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/parse") { if (response.methodResponses?.[0]?.[0] === "CalendarEvent/parse") {
const result = response.methodResponses[0][1]; const result = response.methodResponses[0][1];
console.log('[PARSE DEBUG] CalendarEvent/parse raw result:', JSON.stringify(result, null, 2));
if (result.notParsable && result.notParsable.includes(blobId)) { if (result.notParsable && result.notParsable.includes(blobId)) {
throw new Error("Invalid calendar file format"); throw new Error("Invalid calendar file format");
@@ -2614,14 +2781,7 @@ export class JMAPClient {
} }
async downloadBlob(blobId: string, name?: string, type?: string): Promise<void> { async downloadBlob(blobId: string, name?: string, type?: string): Promise<void> {
const url = this.getBlobDownloadUrl(blobId, name, type); const blob = await this.fetchBlob(blobId, name, type);
const response = await this.authenticatedFetch(url, {});
if (!response.ok) {
throw new Error(`Failed to download attachment: ${response.status}`);
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob); const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
+2
View File
@@ -417,6 +417,7 @@ export interface CalendarEvent {
categories: Record<string, boolean> | null; categories: Record<string, boolean> | null;
locale: string | null; locale: string | null;
replyTo: Record<string, string> | null; replyTo: Record<string, string> | null;
organizerCalendarAddress: string | null;
participants: Record<string, CalendarParticipant> | null; participants: Record<string, CalendarParticipant> | null;
mayInviteSelf: boolean; mayInviteSelf: boolean;
mayInviteOthers: boolean; mayInviteOthers: boolean;
@@ -438,6 +439,7 @@ export interface CalendarParticipant {
'@type': 'Participant'; '@type': 'Participant';
name: string; name: string;
email: string; email: string;
calendarAddress: string | null;
description: string | null; description: string | null;
sendTo: Record<string, string> | null; sendTo: Record<string, string> | null;
kind: 'individual' | 'group' | 'location' | 'resource'; kind: 'individual' | 'group' | 'location' | 'resource';
+61 -2
View File
@@ -289,6 +289,12 @@
"calendar_invitation": { "calendar_invitation": {
"loading": "Veranstaltungsdetails werden geladen…", "loading": "Veranstaltungsdetails werden geladen…",
"title": "Kalendereinladung", "title": "Kalendereinladung",
"published_title": "Veröffentlichtes Ereignis",
"response_title": "Ereignisantwort",
"update_title": "Ereignisaktualisierung",
"counter_title": "Gegenvorschlag",
"refresh_title": "Aktualisierungsanfrage",
"declined_counter_title": "Gegenvorschlag abgelehnt",
"cancelled_title": "Veranstaltung abgesagt", "cancelled_title": "Veranstaltung abgesagt",
"organizer": "Organisiert von {name}", "organizer": "Organisiert von {name}",
"attendees": "{count, plural, one {# Teilnehmer} other {# Teilnehmer}}", "attendees": "{count, plural, one {# Teilnehmer} other {# Teilnehmer}}",
@@ -299,9 +305,54 @@
"added": "Zum Kalender hinzugefügt", "added": "Zum Kalender hinzugefügt",
"rsvp_sent": "Antwort gesendet", "rsvp_sent": "Antwort gesendet",
"parse_error": "Einladung konnte nicht gelesen werden", "parse_error": "Einladung konnte nicht gelesen werden",
"action_failed": "Diese Kalenderaktion konnte nicht abgeschlossen werden.",
"no_calendar": "Kalender nicht verfügbar", "no_calendar": "Kalender nicht verfügbar",
"published_info": "Dieses Ereignis wurde zur Referenz geteilt.",
"response_info": "Diese Nachricht enthält die Antwort eines Teilnehmers.",
"response_info_organizer": "Diese Teilnehmerantwort aktualisiert Ihr Ereignis.",
"update_info": "Diese Nachricht aktualisiert ein bestehendes Ereignis.",
"counter_info": "Diese Nachricht schlägt Änderungen an einem Ereignis vor.",
"counter_info_organizer": "Dieser Teilnehmer hat Änderungen an Ihrem Ereignis vorgeschlagen.",
"refresh_info": "Diese Nachricht fordert die neuesten Ereignisdetails an.",
"refresh_info_organizer": "Ein Teilnehmer hat die neuesten Ereignisdetails angefordert.",
"declined_counter_info": "Der Organisator hat einen Gegenvorschlag abgelehnt.",
"authentication_failed_info": "Die Mail-Authentifizierungsprüfungen für diese Einladung sind fehlgeschlagen. Gehen Sie mit Kalenderaktionen vorsichtig um.",
"authentication_missing_info": "Diese Einladung enthält keine verifizierte Mail-Authentifizierung. Bestätigen Sie die Details mit dem Organisator, wenn etwas ungewöhnlich wirkt.",
"sender_mismatch_info": "Diese Einladung wurde von {sender} gesendet, während der in den Kalenderdaten angegebene Organisator {organizer} ist.",
"sender_mismatch_unverified_info": "Diese Einladung wurde von {sender} gesendet, während der in den Kalenderdaten angegebene Organisator {organizer} ist, und die Nachricht konnte nicht verifiziert werden.",
"organizer_role": "Sie organisieren dieses Ereignis",
"your_response": "Ihre Antwort: {status}",
"response_needed": "Antwort ausstehend",
"response_accepted": "Zugesagt",
"response_tentative": "Vorläufig",
"response_declined": "Abgelehnt",
"response_delegated": "Delegiert",
"actor_sent_info": "Gesendet von {name}.",
"actor_response_info": "{name} hat mit {status} geantwortet.",
"actor_counter_info": "{name} hat Änderungen für dieses Ereignis vorgeschlagen.",
"actor_refresh_info": "{name} hat nach den neuesten Ereignisdetails gefragt.",
"actor_declined_counter_info": "{name} hat den Gegenvorschlag abgelehnt.",
"actor_note": "Hinweis: {comment}",
"actor_unknown": "Jemand",
"proposed_changes": "Vorgeschlagene Änderungen",
"change_title": "Titel",
"change_time": "Zeit",
"change_location": "Ort",
"change_description": "Beschreibung",
"change_empty": "Keine",
"change_from_to": "{before} -> {after}",
"apply_proposal": "Vorgeschlagene Änderungen anwenden",
"proposal_applied": "Die vorgeschlagenen Änderungen wurden angewendet.",
"review_proposal": "Vorschlag prüfen",
"review_request": "Anfrage prüfen",
"view_in_calendar": "Im Kalender anzeigen",
"select_calendar": "Kalender auswählen", "select_calendar": "Kalender auswählen",
"already_in_calendar": "Bereits in deinem Kalender" "already_in_calendar": "Bereits in deinem Kalender",
"request_info": "Du wurdest zu diesem Termin eingeladen. Antworte, um dem Organisator deine Verfügbarkeit mitzuteilen.",
"cancel_info": "Der Organisator hat diesen Termin abgesagt.",
"event_updated": "Aktualisierung #{sequence}",
"event_status_tentative": "Vorläufig",
"event_status_cancelled": "Abgesagt"
}, },
"previous": "Zurück", "previous": "Zurück",
"next": "Weiter", "next": "Weiter",
@@ -621,6 +672,12 @@
"label": "Vorschautext anzeigen", "label": "Vorschautext anzeigen",
"description": "E-Mail-Vorschau in der Liste anzeigen" "description": "E-Mail-Vorschau in der Liste anzeigen"
}, },
"attachment_click_action": {
"label": "Aktion beim Klick auf Anhänge",
"description": "Festlegen, ob ein Dateianhang beim Anklicken in der Vorschau geöffnet oder sofort heruntergeladen wird",
"preview": "Wenn möglich in Vorschau öffnen",
"download": "Sofort herunterladen"
},
"emails_per_page": { "emails_per_page": {
"25": "25 E-Mails", "25": "25 E-Mails",
"50": "50 E-Mails", "50": "50 E-Mails",
@@ -1634,7 +1691,9 @@
"notifications_enabled": "Ereignisbenachrichtigungen", "notifications_enabled": "Ereignisbenachrichtigungen",
"notifications_enabled_desc": "Benachrichtigungen für bevorstehende Termine anzeigen", "notifications_enabled_desc": "Benachrichtigungen für bevorstehende Termine anzeigen",
"notification_sound": "Benachrichtigungston", "notification_sound": "Benachrichtigungston",
"notification_sound_desc": "Ton für Kalenderbenachrichtigungen abspielen" "notification_sound_desc": "Ton für Kalenderbenachrichtigungen abspielen",
"invitation_parsing": "E-Mail-Einladungen verarbeiten",
"invitation_parsing_desc": "Kalendereinladungen in E-Mail-Anhängen erkennen und Kalenderaktionen anzeigen"
}, },
"days": { "days": {
"monday": "Montag", "monday": "Montag",
+61 -2
View File
@@ -292,6 +292,12 @@
"calendar_invitation": { "calendar_invitation": {
"loading": "Loading event details…", "loading": "Loading event details…",
"title": "Calendar Invitation", "title": "Calendar Invitation",
"published_title": "Published Event",
"response_title": "Event Response",
"update_title": "Event Update",
"counter_title": "Counter Proposal",
"refresh_title": "Refresh Request",
"declined_counter_title": "Counter Proposal Declined",
"cancelled_title": "Event Cancelled", "cancelled_title": "Event Cancelled",
"organizer": "Organized by {name}", "organizer": "Organized by {name}",
"attendees": "{count, plural, one {# attendee} other {# attendees}}", "attendees": "{count, plural, one {# attendee} other {# attendees}}",
@@ -302,9 +308,54 @@
"added": "Added to calendar", "added": "Added to calendar",
"rsvp_sent": "Response sent", "rsvp_sent": "Response sent",
"parse_error": "Could not read invitation", "parse_error": "Could not read invitation",
"action_failed": "Could not complete that calendar action.",
"no_calendar": "Calendar not available", "no_calendar": "Calendar not available",
"published_info": "This event was shared for reference.",
"response_info": "This message contains an attendee response.",
"response_info_organizer": "This attendee response updates your event.",
"update_info": "This message updates an existing event.",
"counter_info": "This message proposes changes to an event.",
"counter_info_organizer": "This attendee proposed changes to your event.",
"refresh_info": "This message requests the latest event details.",
"refresh_info_organizer": "An attendee requested the latest event details.",
"declined_counter_info": "The organizer declined a counter proposal.",
"authentication_failed_info": "Mail authentication checks for this invitation failed. Treat calendar actions with caution.",
"authentication_missing_info": "This invitation does not include verified mail authentication. Confirm the details with the organizer if anything looks unusual.",
"sender_mismatch_info": "This invitation was sent from {sender}, while the organizer listed in the calendar data is {organizer}.",
"sender_mismatch_unverified_info": "This invitation was sent from {sender}, while the organizer listed in the calendar data is {organizer}, and the message could not be verified.",
"organizer_role": "You organize this event",
"your_response": "Your response: {status}",
"response_needed": "Needs response",
"response_accepted": "Accepted",
"response_tentative": "Tentative",
"response_declined": "Declined",
"response_delegated": "Delegated",
"actor_sent_info": "Sent by {name}.",
"actor_response_info": "{name} responded {status}.",
"actor_counter_info": "{name} proposed changes to this event.",
"actor_refresh_info": "{name} asked for the latest event details.",
"actor_declined_counter_info": "{name} declined the counter proposal.",
"actor_note": "Note: {comment}",
"actor_unknown": "Someone",
"proposed_changes": "Proposed changes",
"change_title": "Title",
"change_time": "Time",
"change_location": "Location",
"change_description": "Description",
"change_empty": "None",
"change_from_to": "{before} -> {after}",
"apply_proposal": "Apply proposed changes",
"proposal_applied": "Proposed changes applied.",
"review_proposal": "Review proposal",
"review_request": "Review request",
"view_in_calendar": "View in calendar",
"select_calendar": "Select calendar", "select_calendar": "Select calendar",
"already_in_calendar": "Already in your calendar" "already_in_calendar": "Already in your calendar",
"request_info": "You've been invited to this event. Respond to let the organizer know your availability.",
"cancel_info": "The organizer has cancelled this event.",
"event_updated": "Update #{sequence}",
"event_status_tentative": "Tentative",
"event_status_cancelled": "Cancelled"
}, },
"send": "Send" "send": "Send"
}, },
@@ -627,6 +678,12 @@
"label": "Show Preview Text", "label": "Show Preview Text",
"description": "Display email preview in the list" "description": "Display email preview in the list"
}, },
"attachment_click_action": {
"label": "Attachment Click Action",
"description": "Choose whether clicking a file attachment previews it or downloads it immediately",
"preview": "Preview when possible",
"download": "Download immediately"
},
"emails_per_page": { "emails_per_page": {
"25": "25 emails", "25": "25 emails",
"50": "50 emails", "50": "50 emails",
@@ -1654,7 +1711,9 @@
"notifications_enabled": "Event notifications", "notifications_enabled": "Event notifications",
"notifications_enabled_desc": "Show alerts for upcoming calendar events", "notifications_enabled_desc": "Show alerts for upcoming calendar events",
"notification_sound": "Notification sound", "notification_sound": "Notification sound",
"notification_sound_desc": "Play a sound for calendar alerts" "notification_sound_desc": "Play a sound for calendar alerts",
"invitation_parsing": "Parse email invitations",
"invitation_parsing_desc": "Detect calendar invitations in email attachments and show calendar actions"
}, },
"days": { "days": {
"monday": "Monday", "monday": "Monday",
+61 -2
View File
@@ -289,6 +289,12 @@
"calendar_invitation": { "calendar_invitation": {
"loading": "Cargando detalles del evento…", "loading": "Cargando detalles del evento…",
"title": "Invitación de calendario", "title": "Invitación de calendario",
"published_title": "Evento publicado",
"response_title": "Respuesta al evento",
"update_title": "Actualización del evento",
"counter_title": "Contrapropuesta",
"refresh_title": "Solicitud de actualización",
"declined_counter_title": "Contrapropuesta rechazada",
"cancelled_title": "Evento cancelado", "cancelled_title": "Evento cancelado",
"organizer": "Organizado por {name}", "organizer": "Organizado por {name}",
"attendees": "{count, plural, one {# asistente} other {# asistentes}}", "attendees": "{count, plural, one {# asistente} other {# asistentes}}",
@@ -299,9 +305,54 @@
"added": "Añadido al calendario", "added": "Añadido al calendario",
"rsvp_sent": "Respuesta enviada", "rsvp_sent": "Respuesta enviada",
"parse_error": "No se pudo leer la invitación", "parse_error": "No se pudo leer la invitación",
"action_failed": "No se pudo completar esa acción del calendario.",
"no_calendar": "Calendario no disponible", "no_calendar": "Calendario no disponible",
"published_info": "Este evento se compartió como referencia.",
"response_info": "Este mensaje contiene la respuesta de un asistente.",
"response_info_organizer": "Esta respuesta del asistente actualiza su evento.",
"update_info": "Este mensaje actualiza un evento existente.",
"counter_info": "Este mensaje propone cambios a un evento.",
"counter_info_organizer": "Este asistente propuso cambios a su evento.",
"refresh_info": "Este mensaje solicita los últimos detalles del evento.",
"refresh_info_organizer": "Un asistente solicitó los últimos detalles del evento.",
"declined_counter_info": "El organizador rechazó una contrapropuesta.",
"authentication_failed_info": "Las comprobaciones de autenticación de esta invitación fallaron. Trata las acciones de calendario con precaución.",
"authentication_missing_info": "Esta invitación no incluye autenticación de correo verificada. Confirma los detalles con el organizador si algo parece inusual.",
"sender_mismatch_info": "Esta invitación se envió desde {sender}, mientras que el organizador indicado en los datos del calendario es {organizer}.",
"sender_mismatch_unverified_info": "Esta invitación se envió desde {sender}, mientras que el organizador indicado en los datos del calendario es {organizer}, y el mensaje no pudo verificarse.",
"organizer_role": "Organizas este evento",
"your_response": "Tu respuesta: {status}",
"response_needed": "Pendiente de respuesta",
"response_accepted": "Aceptada",
"response_tentative": "Tentativa",
"response_declined": "Rechazada",
"response_delegated": "Delegada",
"actor_sent_info": "Enviado por {name}.",
"actor_response_info": "{name} respondió {status}.",
"actor_counter_info": "{name} propuso cambios para este evento.",
"actor_refresh_info": "{name} pidió los detalles más recientes del evento.",
"actor_declined_counter_info": "{name} rechazó la contrapropuesta.",
"actor_note": "Nota: {comment}",
"actor_unknown": "Alguien",
"proposed_changes": "Cambios propuestos",
"change_title": "Título",
"change_time": "Hora",
"change_location": "Ubicación",
"change_description": "Descripción",
"change_empty": "Ninguno",
"change_from_to": "{before} -> {after}",
"apply_proposal": "Aplicar cambios propuestos",
"proposal_applied": "Se aplicaron los cambios propuestos.",
"review_proposal": "Revisar propuesta",
"review_request": "Revisar solicitud",
"view_in_calendar": "Ver en el calendario",
"select_calendar": "Seleccionar calendario", "select_calendar": "Seleccionar calendario",
"already_in_calendar": "Ya está en tu calendario" "already_in_calendar": "Ya está en tu calendario",
"request_info": "Has sido invitado a este evento. Responde para informar al organizador de tu disponibilidad.",
"cancel_info": "El organizador ha cancelado este evento.",
"event_updated": "Actualización #{sequence}",
"event_status_tentative": "Provisional",
"event_status_cancelled": "Cancelado"
}, },
"previous": "Anterior", "previous": "Anterior",
"next": "Siguiente", "next": "Siguiente",
@@ -621,6 +672,12 @@
"label": "Mostrar Vista Previa", "label": "Mostrar Vista Previa",
"description": "Mostrar vista previa del correo en la lista" "description": "Mostrar vista previa del correo en la lista"
}, },
"attachment_click_action": {
"label": "Acción al hacer clic en adjuntos",
"description": "Elige si al hacer clic en un archivo adjunto se abre una vista previa o se descarga de inmediato",
"preview": "Mostrar vista previa cuando sea posible",
"download": "Descargar inmediatamente"
},
"emails_per_page": { "emails_per_page": {
"25": "25 correos", "25": "25 correos",
"50": "50 correos", "50": "50 correos",
@@ -1634,7 +1691,9 @@
"notifications_enabled": "Notificaciones de eventos", "notifications_enabled": "Notificaciones de eventos",
"notifications_enabled_desc": "Mostrar alertas para eventos próximos", "notifications_enabled_desc": "Mostrar alertas para eventos próximos",
"notification_sound": "Sonido de notificación", "notification_sound": "Sonido de notificación",
"notification_sound_desc": "Reproducir un sonido para las alertas del calendario" "notification_sound_desc": "Reproducir un sonido para las alertas del calendario",
"invitation_parsing": "Analizar invitaciones por correo",
"invitation_parsing_desc": "Detectar invitaciones de calendario en archivos adjuntos del correo y mostrar acciones del calendario"
}, },
"days": { "days": {
"monday": "Lunes", "monday": "Lunes",
+61 -2
View File
@@ -289,6 +289,12 @@
"calendar_invitation": { "calendar_invitation": {
"loading": "Chargement des détails…", "loading": "Chargement des détails…",
"title": "Invitation calendrier", "title": "Invitation calendrier",
"published_title": "Événement publié",
"response_title": "Réponse à l'événement",
"update_title": "Mise à jour de l'événement",
"counter_title": "Contre-proposition",
"refresh_title": "Demande d'actualisation",
"declined_counter_title": "Contre-proposition refusée",
"cancelled_title": "Événement annulé", "cancelled_title": "Événement annulé",
"organizer": "Organisé par {name}", "organizer": "Organisé par {name}",
"attendees": "{count, plural, one {# participant} other {# participants}}", "attendees": "{count, plural, one {# participant} other {# participants}}",
@@ -299,9 +305,54 @@
"added": "Ajouté au calendrier", "added": "Ajouté au calendrier",
"rsvp_sent": "Réponse envoyée", "rsvp_sent": "Réponse envoyée",
"parse_error": "Impossible de lire l'invitation", "parse_error": "Impossible de lire l'invitation",
"action_failed": "Impossible d'effectuer cette action de calendrier.",
"no_calendar": "Calendrier non disponible", "no_calendar": "Calendrier non disponible",
"published_info": "Cet événement a été partagé à titre informatif.",
"response_info": "Ce message contient la réponse d'un participant.",
"response_info_organizer": "Cette réponse met à jour votre événement.",
"update_info": "Ce message met à jour un événement existant.",
"counter_info": "Ce message propose des modifications à un événement.",
"counter_info_organizer": "Ce participant a proposé des modifications à votre événement.",
"refresh_info": "Ce message demande les derniers détails de l'événement.",
"refresh_info_organizer": "Un participant a demandé les derniers détails de l'événement.",
"declined_counter_info": "L'organisateur a refusé une contre-proposition.",
"authentication_failed_info": "Les contrôles d'authentification de cette invitation ont échoué. Traitez les actions de calendrier avec prudence.",
"authentication_missing_info": "Cette invitation ne comporte pas d'authentification de messagerie vérifiée. Vérifiez les détails avec l'organisateur si quelque chose semble inhabituel.",
"sender_mismatch_info": "Cette invitation a été envoyée depuis {sender}, alors que l'organisateur indiqué dans les données du calendrier est {organizer}.",
"sender_mismatch_unverified_info": "Cette invitation a été envoyée depuis {sender}, alors que l'organisateur indiqué dans les données du calendrier est {organizer}, et le message n'a pas pu être vérifié.",
"organizer_role": "Vous organisez cet événement",
"your_response": "Votre réponse : {status}",
"response_needed": "Réponse requise",
"response_accepted": "Acceptée",
"response_tentative": "Provisoire",
"response_declined": "Refusée",
"response_delegated": "Déléguée",
"actor_sent_info": "Envoyé par {name}.",
"actor_response_info": "{name} a répondu {status}.",
"actor_counter_info": "{name} a proposé des modifications à cet événement.",
"actor_refresh_info": "{name} a demandé les derniers détails de l'événement.",
"actor_declined_counter_info": "{name} a refusé la contre-proposition.",
"actor_note": "Note : {comment}",
"actor_unknown": "Quelqu'un",
"proposed_changes": "Modifications proposées",
"change_title": "Titre",
"change_time": "Horaire",
"change_location": "Lieu",
"change_description": "Description",
"change_empty": "Aucune",
"change_from_to": "{before} -> {after}",
"apply_proposal": "Appliquer les modifications proposées",
"proposal_applied": "Les modifications proposées ont été appliquées.",
"review_proposal": "Examiner la proposition",
"review_request": "Examiner la demande",
"view_in_calendar": "Voir dans le calendrier",
"select_calendar": "Choisir un calendrier", "select_calendar": "Choisir un calendrier",
"already_in_calendar": "Déjà dans votre calendrier" "already_in_calendar": "Déjà dans votre calendrier",
"request_info": "Vous êtes invité à cet événement. Répondez pour informer l'organisateur de votre disponibilité.",
"cancel_info": "L'organisateur a annulé cet événement.",
"event_updated": "Mise à jour #{sequence}",
"event_status_tentative": "Provisoire",
"event_status_cancelled": "Annulé"
}, },
"previous": "Précédent", "previous": "Précédent",
"next": "Suivant", "next": "Suivant",
@@ -621,6 +672,12 @@
"label": "Afficher l'aperçu", "label": "Afficher l'aperçu",
"description": "Afficher l'aperçu de l'email dans la liste" "description": "Afficher l'aperçu de l'email dans la liste"
}, },
"attachment_click_action": {
"label": "Action au clic sur les pièces jointes",
"description": "Choisissez si un clic sur une pièce jointe ouvre un aperçu ou lance immédiatement le téléchargement",
"preview": "Aperçu si possible",
"download": "Télécharger immédiatement"
},
"emails_per_page": { "emails_per_page": {
"25": "25 emails", "25": "25 emails",
"50": "50 emails", "50": "50 emails",
@@ -1634,7 +1691,9 @@
"notifications_enabled": "Notifications d'événements", "notifications_enabled": "Notifications d'événements",
"notifications_enabled_desc": "Afficher les alertes pour les événements à venir", "notifications_enabled_desc": "Afficher les alertes pour les événements à venir",
"notification_sound": "Son de notification", "notification_sound": "Son de notification",
"notification_sound_desc": "Jouer un son pour les alertes de calendrier" "notification_sound_desc": "Jouer un son pour les alertes de calendrier",
"invitation_parsing": "Analyser les invitations par e-mail",
"invitation_parsing_desc": "Détecter les invitations de calendrier dans les pièces jointes des e-mails et afficher les actions du calendrier"
}, },
"days": { "days": {
"monday": "Lundi", "monday": "Lundi",
+61 -2
View File
@@ -289,6 +289,12 @@
"calendar_invitation": { "calendar_invitation": {
"loading": "Caricamento dettagli evento…", "loading": "Caricamento dettagli evento…",
"title": "Invito calendario", "title": "Invito calendario",
"published_title": "Evento pubblicato",
"response_title": "Risposta all'evento",
"update_title": "Aggiornamento evento",
"counter_title": "Controproposta",
"refresh_title": "Richiesta di aggiornamento",
"declined_counter_title": "Controproposta rifiutata",
"cancelled_title": "Evento annullato", "cancelled_title": "Evento annullato",
"organizer": "Organizzato da {name}", "organizer": "Organizzato da {name}",
"attendees": "{count, plural, one {# partecipante} other {# partecipanti}}", "attendees": "{count, plural, one {# partecipante} other {# partecipanti}}",
@@ -299,9 +305,54 @@
"added": "Aggiunto al calendario", "added": "Aggiunto al calendario",
"rsvp_sent": "Risposta inviata", "rsvp_sent": "Risposta inviata",
"parse_error": "Impossibile leggere l'invito", "parse_error": "Impossibile leggere l'invito",
"action_failed": "Impossibile completare questa azione del calendario.",
"no_calendar": "Calendario non disponibile", "no_calendar": "Calendario non disponibile",
"published_info": "Questo evento è stato condiviso come riferimento.",
"response_info": "Questo messaggio contiene la risposta di un partecipante.",
"response_info_organizer": "Questa risposta aggiorna il tuo evento.",
"update_info": "Questo messaggio aggiorna un evento esistente.",
"counter_info": "Questo messaggio propone modifiche a un evento.",
"counter_info_organizer": "Questo partecipante ha proposto modifiche al tuo evento.",
"refresh_info": "Questo messaggio richiede gli ultimi dettagli dell'evento.",
"refresh_info_organizer": "Un partecipante ha richiesto gli ultimi dettagli dell'evento.",
"declined_counter_info": "L'organizzatore ha rifiutato una controproposta.",
"authentication_failed_info": "I controlli di autenticazione di questo invito non sono riusciti. Tratta con cautela le azioni sul calendario.",
"authentication_missing_info": "Questo invito non include un'autenticazione email verificata. Conferma i dettagli con l'organizzatore se qualcosa sembra insolito.",
"sender_mismatch_info": "Questo invito è stato inviato da {sender}, mentre l'organizzatore indicato nei dati del calendario è {organizer}.",
"sender_mismatch_unverified_info": "Questo invito è stato inviato da {sender}, mentre l'organizzatore indicato nei dati del calendario è {organizer}, e il messaggio non ha potuto essere verificato.",
"organizer_role": "Sei l'organizzatore di questo evento",
"your_response": "La tua risposta: {status}",
"response_needed": "Risposta richiesta",
"response_accepted": "Accettata",
"response_tentative": "Provvisoria",
"response_declined": "Rifiutata",
"response_delegated": "Delegata",
"actor_sent_info": "Inviato da {name}.",
"actor_response_info": "{name} ha risposto {status}.",
"actor_counter_info": "{name} ha proposto modifiche a questo evento.",
"actor_refresh_info": "{name} ha chiesto i dettagli più recenti dell'evento.",
"actor_declined_counter_info": "{name} ha rifiutato la controproposta.",
"actor_note": "Nota: {comment}",
"actor_unknown": "Qualcuno",
"proposed_changes": "Modifiche proposte",
"change_title": "Titolo",
"change_time": "Orario",
"change_location": "Luogo",
"change_description": "Descrizione",
"change_empty": "Nessuno",
"change_from_to": "{before} -> {after}",
"apply_proposal": "Applica modifiche proposte",
"proposal_applied": "Le modifiche proposte sono state applicate.",
"review_proposal": "Rivedi proposta",
"review_request": "Rivedi richiesta",
"view_in_calendar": "Vedi nel calendario",
"select_calendar": "Seleziona calendario", "select_calendar": "Seleziona calendario",
"already_in_calendar": "Già nel tuo calendario" "already_in_calendar": "Già nel tuo calendario",
"request_info": "Sei stato invitato a questo evento. Rispondi per comunicare la tua disponibilità all'organizzatore.",
"cancel_info": "L'organizzatore ha annullato questo evento.",
"event_updated": "Aggiornamento #{sequence}",
"event_status_tentative": "Provvisorio",
"event_status_cancelled": "Annullato"
}, },
"previous": "Precedente", "previous": "Precedente",
"next": "Successivo", "next": "Successivo",
@@ -621,6 +672,12 @@
"label": "Mostra anteprima testo", "label": "Mostra anteprima testo",
"description": "Visualizza l'anteprima del messaggio nell'elenco" "description": "Visualizza l'anteprima del messaggio nell'elenco"
}, },
"attachment_click_action": {
"label": "Azione al clic sugli allegati",
"description": "Scegli se facendo clic su un allegato si apre un'anteprima o si avvia subito il download",
"preview": "Anteprima quando possibile",
"download": "Scarica immediatamente"
},
"emails_per_page": { "emails_per_page": {
"25": "25 messaggi", "25": "25 messaggi",
"50": "50 messaggi", "50": "50 messaggi",
@@ -1634,7 +1691,9 @@
"notifications_enabled": "Notifiche eventi", "notifications_enabled": "Notifiche eventi",
"notifications_enabled_desc": "Mostra avvisi per gli eventi in arrivo", "notifications_enabled_desc": "Mostra avvisi per gli eventi in arrivo",
"notification_sound": "Suono di notifica", "notification_sound": "Suono di notifica",
"notification_sound_desc": "Riproduci un suono per gli avvisi del calendario" "notification_sound_desc": "Riproduci un suono per gli avvisi del calendario",
"invitation_parsing": "Analizza gli inviti email",
"invitation_parsing_desc": "Rileva gli inviti del calendario negli allegati email e mostra le azioni del calendario"
}, },
"days": { "days": {
"monday": "Lunedì", "monday": "Lunedì",
+61 -2
View File
@@ -289,6 +289,12 @@
"calendar_invitation": { "calendar_invitation": {
"loading": "イベント詳細を読み込み中…", "loading": "イベント詳細を読み込み中…",
"title": "カレンダー招待", "title": "カレンダー招待",
"published_title": "公開イベント",
"response_title": "イベント返信",
"update_title": "イベント更新",
"counter_title": "対案",
"refresh_title": "更新依頼",
"declined_counter_title": "対案は拒否されました",
"cancelled_title": "イベントがキャンセルされました", "cancelled_title": "イベントがキャンセルされました",
"organizer": "{name} が主催", "organizer": "{name} が主催",
"attendees": "{count}名の参加者", "attendees": "{count}名の参加者",
@@ -299,9 +305,54 @@
"added": "カレンダーに追加しました", "added": "カレンダーに追加しました",
"rsvp_sent": "回答を送信しました", "rsvp_sent": "回答を送信しました",
"parse_error": "招待を読み込めませんでした", "parse_error": "招待を読み込めませんでした",
"action_failed": "このカレンダー操作を完了できませんでした。",
"no_calendar": "カレンダーが利用できません", "no_calendar": "カレンダーが利用できません",
"published_info": "このイベントは参照用として共有されました。",
"response_info": "このメッセージには参加者の返信が含まれています。",
"response_info_organizer": "この参加者の返信はあなたのイベントを更新します。",
"update_info": "このメッセージは既存のイベントを更新します。",
"counter_info": "このメッセージはイベント変更の提案です。",
"counter_info_organizer": "この参加者はあなたのイベントに変更を提案しました。",
"refresh_info": "このメッセージは最新のイベント詳細を要求しています。",
"refresh_info_organizer": "参加者が最新のイベント詳細を要求しました。",
"declined_counter_info": "主催者が対案を拒否しました。",
"authentication_failed_info": "この招待メールの認証チェックに失敗しました。カレンダー操作は慎重に行ってください。",
"authentication_missing_info": "この招待には検証済みのメール認証がありません。不審な点があれば主催者に詳細を確認してください。",
"sender_mismatch_info": "この招待は {sender} から送信されましたが、カレンダーデータ上の主催者は {organizer} です。",
"sender_mismatch_unverified_info": "この招待は {sender} から送信されましたが、カレンダーデータ上の主催者は {organizer} であり、メッセージも検証できませんでした。",
"organizer_role": "あなたがこのイベントの主催者です",
"your_response": "あなたの返答: {status}",
"response_needed": "返答が必要です",
"response_accepted": "承諾",
"response_tentative": "未定",
"response_declined": "辞退",
"response_delegated": "委任済み",
"actor_sent_info": "{name} から送信されました。",
"actor_response_info": "{name} が {status} と返答しました。",
"actor_counter_info": "{name} がこのイベントの変更を提案しました。",
"actor_refresh_info": "{name} が最新のイベント詳細を求めました。",
"actor_declined_counter_info": "{name} が対案を拒否しました。",
"actor_note": "メモ: {comment}",
"actor_unknown": "どなたか",
"proposed_changes": "提案された変更",
"change_title": "件名",
"change_time": "日時",
"change_location": "場所",
"change_description": "説明",
"change_empty": "なし",
"change_from_to": "{before} -> {after}",
"apply_proposal": "提案された変更を適用",
"proposal_applied": "提案された変更を適用しました。",
"review_proposal": "提案を確認",
"review_request": "依頼を確認",
"view_in_calendar": "カレンダーで表示",
"select_calendar": "カレンダーを選択", "select_calendar": "カレンダーを選択",
"already_in_calendar": "カレンダーに登録済み" "already_in_calendar": "カレンダーに登録済み",
"request_info": "このイベントに招待されました。主催者に参加可否をお知らせください。",
"cancel_info": "主催者がこのイベントをキャンセルしました。",
"event_updated": "更新 #{sequence}",
"event_status_tentative": "仮",
"event_status_cancelled": "キャンセル済み"
}, },
"previous": "前へ", "previous": "前へ",
"next": "次へ", "next": "次へ",
@@ -621,6 +672,12 @@
"label": "プレビューテキストを表示", "label": "プレビューテキストを表示",
"description": "リストにメールのプレビューを表示" "description": "リストにメールのプレビューを表示"
}, },
"attachment_click_action": {
"label": "添付ファイルクリック時の動作",
"description": "添付ファイルをクリックしたときに、プレビューを開くかすぐにダウンロードするかを選択します",
"preview": "可能ならプレビューを開く",
"download": "すぐにダウンロード"
},
"emails_per_page": { "emails_per_page": {
"25": "25件", "25": "25件",
"50": "50件", "50": "50件",
@@ -1634,7 +1691,9 @@
"notifications_enabled": "イベント通知", "notifications_enabled": "イベント通知",
"notifications_enabled_desc": "予定のイベントのアラートを表示する", "notifications_enabled_desc": "予定のイベントのアラートを表示する",
"notification_sound": "通知音", "notification_sound": "通知音",
"notification_sound_desc": "カレンダーアラートの音を鳴らす" "notification_sound_desc": "カレンダーアラートの音を鳴らす",
"invitation_parsing": "メール招待を解析する",
"invitation_parsing_desc": "メール添付のカレンダー招待を検出してカレンダー操作を表示する"
}, },
"days": { "days": {
"monday": "月曜日", "monday": "月曜日",
+61 -2
View File
@@ -289,6 +289,12 @@
"calendar_invitation": { "calendar_invitation": {
"loading": "Evenementdetails laden…", "loading": "Evenementdetails laden…",
"title": "Agenda-uitnodiging", "title": "Agenda-uitnodiging",
"published_title": "Gepubliceerd evenement",
"response_title": "Reactie op evenement",
"update_title": "Evenementupdate",
"counter_title": "Tegenvoorstel",
"refresh_title": "Vernieuwingsverzoek",
"declined_counter_title": "Tegenvoorstel afgewezen",
"cancelled_title": "Evenement geannuleerd", "cancelled_title": "Evenement geannuleerd",
"organizer": "Georganiseerd door {name}", "organizer": "Georganiseerd door {name}",
"attendees": "{count, plural, one {# deelnemer} other {# deelnemers}}", "attendees": "{count, plural, one {# deelnemer} other {# deelnemers}}",
@@ -299,9 +305,54 @@
"added": "Toegevoegd aan agenda", "added": "Toegevoegd aan agenda",
"rsvp_sent": "Reactie verzonden", "rsvp_sent": "Reactie verzonden",
"parse_error": "Kan uitnodiging niet lezen", "parse_error": "Kan uitnodiging niet lezen",
"action_failed": "Die agenda-actie kon niet worden voltooid.",
"no_calendar": "Agenda niet beschikbaar", "no_calendar": "Agenda niet beschikbaar",
"published_info": "Dit evenement is gedeeld ter referentie.",
"response_info": "Dit bericht bevat een reactie van een deelnemer.",
"response_info_organizer": "Deze reactie werkt uw evenement bij.",
"update_info": "Dit bericht werkt een bestaand evenement bij.",
"counter_info": "Dit bericht stelt wijzigingen aan een evenement voor.",
"counter_info_organizer": "Deze deelnemer stelde wijzigingen aan uw evenement voor.",
"refresh_info": "Dit bericht vraagt om de nieuwste evenementdetails.",
"refresh_info_organizer": "Een deelnemer vroeg om de nieuwste evenementdetails.",
"declined_counter_info": "De organisator heeft een tegenvoorstel afgewezen.",
"authentication_failed_info": "De mailauthenticatiecontroles voor deze uitnodiging zijn mislukt. Wees voorzichtig met agenda-acties.",
"authentication_missing_info": "Deze uitnodiging bevat geen geverifieerde mailauthenticatie. Controleer de details bij de organisator als iets ongewoon lijkt.",
"sender_mismatch_info": "Deze uitnodiging is verzonden vanaf {sender}, terwijl de organisator in de kalendergegevens {organizer} is.",
"sender_mismatch_unverified_info": "Deze uitnodiging is verzonden vanaf {sender}, terwijl de organisator in de kalendergegevens {organizer} is, en het bericht kon niet worden geverifieerd.",
"organizer_role": "U organiseert dit evenement",
"your_response": "Uw reactie: {status}",
"response_needed": "Reactie vereist",
"response_accepted": "Geaccepteerd",
"response_tentative": "Voorlopig",
"response_declined": "Geweigerd",
"response_delegated": "Gedelegeerd",
"actor_sent_info": "Verzonden door {name}.",
"actor_response_info": "{name} reageerde {status}.",
"actor_counter_info": "{name} stelde wijzigingen voor dit evenement voor.",
"actor_refresh_info": "{name} vroeg om de nieuwste evenementdetails.",
"actor_declined_counter_info": "{name} wees het tegenvoorstel af.",
"actor_note": "Notitie: {comment}",
"actor_unknown": "Iemand",
"proposed_changes": "Voorgestelde wijzigingen",
"change_title": "Titel",
"change_time": "Tijd",
"change_location": "Locatie",
"change_description": "Beschrijving",
"change_empty": "Geen",
"change_from_to": "{before} -> {after}",
"apply_proposal": "Voorgestelde wijzigingen toepassen",
"proposal_applied": "De voorgestelde wijzigingen zijn toegepast.",
"review_proposal": "Voorstel bekijken",
"review_request": "Verzoek bekijken",
"view_in_calendar": "In agenda bekijken",
"select_calendar": "Agenda selecteren", "select_calendar": "Agenda selecteren",
"already_in_calendar": "Staat al in je agenda" "already_in_calendar": "Staat al in je agenda",
"request_info": "Je bent uitgenodigd voor dit evenement. Reageer om de organisator te laten weten of je kunt deelnemen.",
"cancel_info": "De organisator heeft dit evenement geannuleerd.",
"event_updated": "Update #{sequence}",
"event_status_tentative": "Voorlopig",
"event_status_cancelled": "Geannuleerd"
}, },
"previous": "Vorige", "previous": "Vorige",
"next": "Volgende", "next": "Volgende",
@@ -621,6 +672,12 @@
"label": "Voorbeeldtekst tonen", "label": "Voorbeeldtekst tonen",
"description": "E-mailvoorbeeld weergeven in de lijst" "description": "E-mailvoorbeeld weergeven in de lijst"
}, },
"attachment_click_action": {
"label": "Actie bij klikken op bijlagen",
"description": "Kies of een klik op een bijlage een voorbeeld opent of direct downloadt",
"preview": "Voorbeeld tonen indien mogelijk",
"download": "Direct downloaden"
},
"emails_per_page": { "emails_per_page": {
"25": "25 e-mails", "25": "25 e-mails",
"50": "50 e-mails", "50": "50 e-mails",
@@ -1634,7 +1691,9 @@
"notifications_enabled": "Evenementmeldingen", "notifications_enabled": "Evenementmeldingen",
"notifications_enabled_desc": "Meldingen weergeven voor aankomende evenementen", "notifications_enabled_desc": "Meldingen weergeven voor aankomende evenementen",
"notification_sound": "Meldingsgeluid", "notification_sound": "Meldingsgeluid",
"notification_sound_desc": "Geluid afspelen voor agendameldingen" "notification_sound_desc": "Geluid afspelen voor agendameldingen",
"invitation_parsing": "E-mailuitnodigingen verwerken",
"invitation_parsing_desc": "Kalenderuitnodigingen in e-mailbijlagen detecteren en kalenderacties tonen"
}, },
"days": { "days": {
"monday": "Maandag", "monday": "Maandag",
+61 -2
View File
@@ -289,6 +289,12 @@
"calendar_invitation": { "calendar_invitation": {
"loading": "Carregando detalhes do evento…", "loading": "Carregando detalhes do evento…",
"title": "Convite de calendário", "title": "Convite de calendário",
"published_title": "Evento publicado",
"response_title": "Resposta do evento",
"update_title": "Atualização do evento",
"counter_title": "Contraproposta",
"refresh_title": "Solicitação de atualização",
"declined_counter_title": "Contraproposta recusada",
"cancelled_title": "Evento cancelado", "cancelled_title": "Evento cancelado",
"organizer": "Organizado por {name}", "organizer": "Organizado por {name}",
"attendees": "{count, plural, one {# participante} other {# participantes}}", "attendees": "{count, plural, one {# participante} other {# participantes}}",
@@ -299,9 +305,54 @@
"added": "Adicionado ao calendário", "added": "Adicionado ao calendário",
"rsvp_sent": "Resposta enviada", "rsvp_sent": "Resposta enviada",
"parse_error": "Não foi possível ler o convite", "parse_error": "Não foi possível ler o convite",
"action_failed": "Não foi possível concluir essa ação do calendário.",
"no_calendar": "Calendário não disponível", "no_calendar": "Calendário não disponível",
"published_info": "Este evento foi compartilhado como referência.",
"response_info": "Esta mensagem contém a resposta de um participante.",
"response_info_organizer": "Esta resposta de participante atualiza seu evento.",
"update_info": "Esta mensagem atualiza um evento existente.",
"counter_info": "Esta mensagem propõe alterações em um evento.",
"counter_info_organizer": "Este participante propôs alterações ao seu evento.",
"refresh_info": "Esta mensagem solicita os detalhes mais recentes do evento.",
"refresh_info_organizer": "Um participante solicitou os detalhes mais recentes do evento.",
"declined_counter_info": "O organizador recusou uma contraproposta.",
"authentication_failed_info": "As verificações de autenticação deste convite falharam. Trate as ações de calendário com cautela.",
"authentication_missing_info": "Este convite não inclui autenticação de email verificada. Confirme os detalhes com o organizador se algo parecer incomum.",
"sender_mismatch_info": "Este convite foi enviado por {sender}, enquanto o organizador indicado nos dados do calendário é {organizer}.",
"sender_mismatch_unverified_info": "Este convite foi enviado por {sender}, enquanto o organizador indicado nos dados do calendário é {organizer}, e a mensagem não pôde ser verificada.",
"organizer_role": "Você organiza este evento",
"your_response": "Sua resposta: {status}",
"response_needed": "Resposta necessária",
"response_accepted": "Aceita",
"response_tentative": "Provisória",
"response_declined": "Recusada",
"response_delegated": "Delegada",
"actor_sent_info": "Enviado por {name}.",
"actor_response_info": "{name} respondeu {status}.",
"actor_counter_info": "{name} propôs alterações para este evento.",
"actor_refresh_info": "{name} pediu os detalhes mais recentes do evento.",
"actor_declined_counter_info": "{name} recusou a contraproposta.",
"actor_note": "Nota: {comment}",
"actor_unknown": "Alguém",
"proposed_changes": "Alterações propostas",
"change_title": "Título",
"change_time": "Horário",
"change_location": "Local",
"change_description": "Descrição",
"change_empty": "Nenhuma",
"change_from_to": "{before} -> {after}",
"apply_proposal": "Aplicar alterações propostas",
"proposal_applied": "As alterações propostas foram aplicadas.",
"review_proposal": "Revisar proposta",
"review_request": "Revisar solicitação",
"view_in_calendar": "Ver no calendário",
"select_calendar": "Selecionar calendário", "select_calendar": "Selecionar calendário",
"already_in_calendar": "Já está no seu calendário" "already_in_calendar": "Já está no seu calendário",
"request_info": "Você foi convidado para este evento. Responda para informar o organizador sobre sua disponibilidade.",
"cancel_info": "O organizador cancelou este evento.",
"event_updated": "Atualização #{sequence}",
"event_status_tentative": "Provisório",
"event_status_cancelled": "Cancelado"
}, },
"previous": "Anterior", "previous": "Anterior",
"next": "Próximo", "next": "Próximo",
@@ -621,6 +672,12 @@
"label": "Mostrar Texto de Visualização", "label": "Mostrar Texto de Visualização",
"description": "Exibir visualização do e-mail na lista" "description": "Exibir visualização do e-mail na lista"
}, },
"attachment_click_action": {
"label": "Ação ao clicar em anexos",
"description": "Escolha se clicar em um anexo abre uma pré-visualização ou inicia o download imediatamente",
"preview": "Visualizar quando possível",
"download": "Baixar imediatamente"
},
"emails_per_page": { "emails_per_page": {
"25": "25 e-mails", "25": "25 e-mails",
"50": "50 e-mails", "50": "50 e-mails",
@@ -1634,7 +1691,9 @@
"notifications_enabled": "Notificações de eventos", "notifications_enabled": "Notificações de eventos",
"notifications_enabled_desc": "Mostrar alertas para eventos próximos", "notifications_enabled_desc": "Mostrar alertas para eventos próximos",
"notification_sound": "Som de notificação", "notification_sound": "Som de notificação",
"notification_sound_desc": "Reproduzir um som para alertas do calendário" "notification_sound_desc": "Reproduzir um som para alertas do calendário",
"invitation_parsing": "Analisar convites por e-mail",
"invitation_parsing_desc": "Detectar convites de calendário em anexos de e-mail e mostrar ações do calendário"
}, },
"days": { "days": {
"monday": "Segunda-feira", "monday": "Segunda-feira",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,32 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { useSettingsStore } from '../settings-store';
describe('settings-store attachment action', () => {
beforeEach(() => {
useSettingsStore.getState().resetToDefaults();
});
it('defaults to preview when settings are reset', () => {
expect(useSettingsStore.getState().mailAttachmentAction).toBe('preview');
});
it('includes the attachment action in exported settings', () => {
useSettingsStore.getState().updateSetting('mailAttachmentAction', 'download');
const exported = JSON.parse(useSettingsStore.getState().exportSettings()) as {
mailAttachmentAction?: string;
};
expect(exported.mailAttachmentAction).toBe('download');
});
it('includes calendar invitation parsing in exported settings', () => {
useSettingsStore.getState().updateSetting('calendarInvitationParsingEnabled', false);
const exported = JSON.parse(useSettingsStore.getState().exportSettings()) as {
calendarInvitationParsingEnabled?: boolean;
};
expect(exported.calendarInvitationParsingEnabled).toBe(false);
});
});
+17 -5
View File
@@ -36,7 +36,7 @@ interface CalendarStore {
createEvent: (client: JMAPClient, event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => Promise<CalendarEvent | null>; createEvent: (client: JMAPClient, event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => Promise<CalendarEvent | null>;
updateEvent: (client: JMAPClient, id: string, updates: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => Promise<void>; updateEvent: (client: JMAPClient, id: string, updates: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => Promise<void>;
deleteEvent: (client: JMAPClient, id: string, sendSchedulingMessages?: boolean) => Promise<void>; deleteEvent: (client: JMAPClient, id: string, sendSchedulingMessages?: boolean) => Promise<void>;
rsvpEvent: (client: JMAPClient, eventId: string, participantId: string, status: string) => Promise<void>; rsvpEvent: (client: JMAPClient, eventId: string, participantId: string, status: string, replyTo?: Record<string, string> | null) => Promise<void>;
importEvents: (client: JMAPClient, events: Partial<CalendarEvent>[], calendarId: string) => Promise<number>; importEvents: (client: JMAPClient, events: Partial<CalendarEvent>[], calendarId: string) => Promise<number>;
updateCalendar: (client: JMAPClient, calendarId: string, updates: Partial<Calendar>) => Promise<void>; updateCalendar: (client: JMAPClient, calendarId: string, updates: Partial<Calendar>) => Promise<void>;
createCalendar: (client: JMAPClient, calendar: Partial<Calendar>) => Promise<Calendar | null>; createCalendar: (client: JMAPClient, calendar: Partial<Calendar>) => Promise<Calendar | null>;
@@ -138,17 +138,27 @@ export const useCalendarStore = create<CalendarStore>()(
} }
}, },
rsvpEvent: async (client, eventId, participantId, status) => { rsvpEvent: async (client, eventId, participantId, status, replyTo) => {
set({ error: null }); set({ error: null });
if (!/^[a-zA-Z0-9_-]+$/.test(participantId)) { // JMAP participant IDs are opaque strings — they can contain @, ., :, / etc.
// Only reject empty or obviously malicious values (path traversal).
if (!participantId || participantId.includes('..')) {
set({ error: 'Invalid participant ID' }); set({ error: 'Invalid participant ID' });
throw new Error('Invalid participant ID'); throw new Error('Invalid participant ID');
} }
try { try {
const patchKey = `participants/${participantId}/participationStatus`; // Escape per RFC 6901 (JSON Pointer): ~ → ~0, / → ~1
const escapedId = participantId.replace(/~/g, '~0').replace(/\//g, '~1');
const patchKey = `participants/${escapedId}/participationStatus`;
const patch: Record<string, unknown> = { [patchKey]: status };
// Include replyTo so the server knows where to deliver the iTIP reply
// (may be missing if the event was imported or auto-created without it).
if (replyTo) {
patch.replyTo = replyTo;
}
await client.updateCalendarEvent( await client.updateCalendarEvent(
eventId, eventId,
{ [patchKey]: status } as unknown as Partial<CalendarEvent>, patch as unknown as Partial<CalendarEvent>,
true true
); );
set((state) => ({ set((state) => ({
@@ -183,6 +193,7 @@ export const useCalendarStore = create<CalendarStore>()(
'@type': 'Participant', '@type': 'Participant',
name: p.name, name: p.name,
email: p.email, email: p.email,
calendarAddress: p.calendarAddress,
description: p.description, description: p.description,
sendTo: p.sendTo, sendTo: p.sendTo,
kind: p.kind, kind: p.kind,
@@ -224,6 +235,7 @@ export const useCalendarStore = create<CalendarStore>()(
keywords: src.keywords, keywords: src.keywords,
categories: src.categories, categories: src.categories,
locale: src.locale, locale: src.locale,
replyTo: src.replyTo || (src.organizerCalendarAddress ? { imip: src.organizerCalendarAddress } : undefined),
locations: src.locations, locations: src.locations,
virtualLocations: src.virtualLocations, virtualLocations: src.virtualLocations,
links: src.links, links: src.links,
+7
View File
@@ -28,6 +28,7 @@ export type DateFormat = 'regional' | 'iso' | 'custom';
export type TimeFormat = '12h' | '24h'; export type TimeFormat = '12h' | '24h';
export type FirstDayOfWeek = 0 | 1; // 0 = Sunday, 1 = Monday export type FirstDayOfWeek = 0 | 1; // 0 = Sunday, 1 = Monday
export type ExternalContentPolicy = 'ask' | 'block' | 'allow'; export type ExternalContentPolicy = 'ask' | 'block' | 'allow';
export type MailAttachmentAction = 'preview' | 'download';
export type ToolbarPosition = 'top' | 'below-subject'; export type ToolbarPosition = 'top' | 'below-subject';
export interface KeywordDefinition { export interface KeywordDefinition {
@@ -81,6 +82,7 @@ interface SettingsState {
showPreview: boolean; showPreview: boolean;
emailsPerPage: number; emailsPerPage: number;
externalContentPolicy: ExternalContentPolicy; externalContentPolicy: ExternalContentPolicy;
mailAttachmentAction: MailAttachmentAction;
// Composer // Composer
autoSaveDraftInterval: number; // milliseconds autoSaveDraftInterval: number; // milliseconds
@@ -94,6 +96,7 @@ interface SettingsState {
// Calendar Notifications // Calendar Notifications
calendarNotificationsEnabled: boolean; calendarNotificationsEnabled: boolean;
calendarNotificationSound: boolean; calendarNotificationSound: boolean;
calendarInvitationParsingEnabled: boolean;
// Layout // Layout
toolbarPosition: ToolbarPosition; toolbarPosition: ToolbarPosition;
@@ -160,6 +163,7 @@ const DEFAULT_SETTINGS = {
showPreview: true, showPreview: true,
emailsPerPage: 50, emailsPerPage: 50,
externalContentPolicy: 'ask' as ExternalContentPolicy, externalContentPolicy: 'ask' as ExternalContentPolicy,
mailAttachmentAction: 'preview' as MailAttachmentAction,
// Composer // Composer
autoSaveDraftInterval: 60000, // 1 minute autoSaveDraftInterval: 60000, // 1 minute
@@ -173,6 +177,7 @@ const DEFAULT_SETTINGS = {
// Calendar Notifications // Calendar Notifications
calendarNotificationsEnabled: true, calendarNotificationsEnabled: true,
calendarNotificationSound: true, calendarNotificationSound: true,
calendarInvitationParsingEnabled: true,
// Layout // Layout
toolbarPosition: 'top' as ToolbarPosition, toolbarPosition: 'top' as ToolbarPosition,
@@ -237,6 +242,7 @@ export const useSettingsStore = create<SettingsState>()(
showPreview: state.showPreview, showPreview: state.showPreview,
emailsPerPage: state.emailsPerPage, emailsPerPage: state.emailsPerPage,
externalContentPolicy: state.externalContentPolicy, externalContentPolicy: state.externalContentPolicy,
mailAttachmentAction: state.mailAttachmentAction,
trustedSenders: state.trustedSenders, trustedSenders: state.trustedSenders,
autoSaveDraftInterval: state.autoSaveDraftInterval, autoSaveDraftInterval: state.autoSaveDraftInterval,
sendConfirmation: state.sendConfirmation, sendConfirmation: state.sendConfirmation,
@@ -244,6 +250,7 @@ export const useSettingsStore = create<SettingsState>()(
sessionTimeout: state.sessionTimeout, sessionTimeout: state.sessionTimeout,
calendarNotificationsEnabled: state.calendarNotificationsEnabled, calendarNotificationsEnabled: state.calendarNotificationsEnabled,
calendarNotificationSound: state.calendarNotificationSound, calendarNotificationSound: state.calendarNotificationSound,
calendarInvitationParsingEnabled: state.calendarInvitationParsingEnabled,
toolbarPosition: state.toolbarPosition, toolbarPosition: state.toolbarPosition,
senderFavicons: state.senderFavicons, senderFavicons: state.senderFavicons,
folderIcons: state.folderIcons, folderIcons: state.folderIcons,