feat: calendar invitations RSVP, trust assessment, file preview
This commit is contained in:
@@ -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)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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');
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user