mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
fix: send the RSVP for invitations outside the loaded calendar window
Accepting an invitation looked the event up in the calendar store,
which only holds the window the calendar last showed. For an event
further out the lookup came back empty, no response was sent, and the
banner still said "Added to calendar".
- find the event on the server with a `uid` filter, like webmail's
`queryCalendarEvents({ uid })`, both when the banner opens (so it
shows as already in the calendar) and after importing it
- let `rsvpEvent` take the event when it isn't in the store, so the
organizer repair still applies
- show "Could not send your response" whenever no response went out
This commit is contained in:
@@ -16,6 +16,7 @@ import {
|
|||||||
createEvent,
|
createEvent,
|
||||||
updateEvent,
|
updateEvent,
|
||||||
deleteEvents,
|
deleteEvents,
|
||||||
|
findEventsByUid,
|
||||||
toLocalDateTime,
|
toLocalDateTime,
|
||||||
} from '../calendar';
|
} from '../calendar';
|
||||||
|
|
||||||
@@ -337,6 +338,35 @@ describe('calendar operations', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('findEventsByUid', () => {
|
||||||
|
it('queries by uid, whatever the date, and fetches the matches', async () => {
|
||||||
|
mockRequest
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
methodResponses: [['CalendarEvent/query', { ids: ['ev9'] }, '0']],
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
methodResponses: [['CalendarEvent/get', { list: [{ id: 'ev9', uid: 'inv@example.com' }] }, '0']],
|
||||||
|
});
|
||||||
|
|
||||||
|
const found = await findEventsByUid('inv@example.com');
|
||||||
|
|
||||||
|
const query = mockRequest.mock.calls[0][0][0];
|
||||||
|
expect(query[0]).toBe('CalendarEvent/query');
|
||||||
|
expect(query[1]).toEqual({ accountId: 'acc-1', filter: { uid: 'inv@example.com' } });
|
||||||
|
expect(mockRequest.mock.calls[1][0][0][1].ids).toEqual(['ev9']);
|
||||||
|
expect(found.map((e) => e.id)).toEqual(['ev9']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns nothing without a second request when no event matches', async () => {
|
||||||
|
mockRequest.mockResolvedValueOnce({
|
||||||
|
methodResponses: [['CalendarEvent/query', { ids: [] }, '0']],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await findEventsByUid('missing@example.com')).toEqual([]);
|
||||||
|
expect(mockRequest).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('deleteEvents', () => {
|
describe('deleteEvents', () => {
|
||||||
it('should destroy events by ids', async () => {
|
it('should destroy events by ids', async () => {
|
||||||
mockRequest.mockResolvedValue({
|
mockRequest.mockResolvedValue({
|
||||||
|
|||||||
@@ -388,6 +388,21 @@ export async function getEvents(ids: string[], accountId?: string): Promise<Cale
|
|||||||
return all;
|
return all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The calendar objects carrying an iCalendar UID, found on the server with a
|
||||||
|
* `uid` filter — unlike the store, not limited to the loaded date window.
|
||||||
|
* Mirrors webmail's `queryCalendarEvents({ uid })`.
|
||||||
|
*/
|
||||||
|
export async function findEventsByUid(uid: string, accountId?: string): Promise<CalendarEvent[]> {
|
||||||
|
const account = accountId || jmapClient.accountId;
|
||||||
|
const res = await jmapClient.request(
|
||||||
|
[['CalendarEvent/query', { accountId: account, filter: { uid } }, '0']],
|
||||||
|
USING,
|
||||||
|
);
|
||||||
|
const ids = methodResult<{ ids: string[] }>(res).ids ?? [];
|
||||||
|
return getEvents(ids, accountId);
|
||||||
|
}
|
||||||
|
|
||||||
// `sendSchedulingMessages` asks Stalwart to deliver iMIP (RFC 6047) invitation
|
// `sendSchedulingMessages` asks Stalwart to deliver iMIP (RFC 6047) invitation
|
||||||
// / reply / cancellation emails to participants. We pass it whenever an event
|
// / reply / cancellation emails to participants. We pass it whenever an event
|
||||||
// has participants so creating/updating/deleting a meeting notifies attendees.
|
// has participants so creating/updating/deleting a meeting notifies attendees.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { format, parseISO } from 'date-fns';
|
|||||||
import type { Calendar, Email, CalendarEvent } from '../../api/types';
|
import type { Calendar, Email, CalendarEvent } from '../../api/types';
|
||||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
import { useColors } from '../../theme/colors';
|
import { useColors } from '../../theme/colors';
|
||||||
import { fetchCalendarBlobText, parseCalendarBlob } from '../../api/calendar';
|
import { fetchCalendarBlobText, findEventsByUid, parseCalendarBlob } from '../../api/calendar';
|
||||||
import { useCalendarStore } from '../../stores/calendar-store';
|
import { useCalendarStore } from '../../stores/calendar-store';
|
||||||
import { useSettingsStore } from '../../stores/settings-store';
|
import { useSettingsStore } from '../../stores/settings-store';
|
||||||
import { useLocaleStore } from '../../stores/locale-store';
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
@@ -76,6 +76,9 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) {
|
|||||||
|
|
||||||
const [state, setState] = React.useState<BannerState>('loading');
|
const [state, setState] = React.useState<BannerState>('loading');
|
||||||
const [event, setEvent] = React.useState<Partial<CalendarEvent> | null>(null);
|
const [event, setEvent] = React.useState<Partial<CalendarEvent> | null>(null);
|
||||||
|
// The invitation's event as found on the server by UID, for one outside
|
||||||
|
// the calendar's loaded window.
|
||||||
|
const [serverMatch, setServerMatch] = React.useState<CalendarEvent | null>(null);
|
||||||
const [method, setMethod] = React.useState<InvitationMethod>('unknown');
|
const [method, setMethod] = React.useState<InvitationMethod>('unknown');
|
||||||
const [rsvpStatus, setRsvpStatus] = React.useState<RsvpStatus | null>(null);
|
const [rsvpStatus, setRsvpStatus] = React.useState<RsvpStatus | null>(null);
|
||||||
const [notice, setNotice] = React.useState<string | null>(null);
|
const [notice, setNotice] = React.useState<string | null>(null);
|
||||||
@@ -87,6 +90,7 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) {
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
if (!attachment || !enabled) return;
|
if (!attachment || !enabled) return;
|
||||||
setState('loading');
|
setState('loading');
|
||||||
|
setServerMatch(null);
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const events = await parseCalendarBlob(attachment.blobId, jmapAccountId);
|
const events = await parseCalendarBlob(attachment.blobId, jmapAccountId);
|
||||||
@@ -97,6 +101,14 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) {
|
|||||||
}
|
}
|
||||||
const parsed = events[0];
|
const parsed = events[0];
|
||||||
setEvent(parsed);
|
setEvent(parsed);
|
||||||
|
// The store only holds the calendar's loaded window; look the event
|
||||||
|
// up on the server so one already there counts as existing and the
|
||||||
|
// RSVP goes to it. Best-effort, must not block the banner.
|
||||||
|
if (parsed.uid && !useCalendarStore.getState().events.some((e) => e.uid === parsed.uid)) {
|
||||||
|
findEventsByUid(parsed.uid)
|
||||||
|
.then((found) => { if (!cancelled && found[0]) setServerMatch(found[0]); })
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
// Explicit method (Content-Type params) first; JMAP usually strips
|
// Explicit method (Content-Type params) first; JMAP usually strips
|
||||||
// them, so fall back to the raw ICS METHOD line before guessing.
|
// them, so fall back to the raw ICS METHOD line before guessing.
|
||||||
let detected = getInvitationMethod(parsed, { email, attachment });
|
let detected = getInvitationMethod(parsed, { email, attachment });
|
||||||
@@ -126,11 +138,12 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) {
|
|||||||
?? candidates.find((cal) => cal.isDefault)
|
?? candidates.find((cal) => cal.isDefault)
|
||||||
?? candidates[0];
|
?? candidates[0];
|
||||||
|
|
||||||
// Already imported? Look for the UID among the loaded events.
|
// Already imported? Look for the UID among the loaded events, then among
|
||||||
|
// what the server lookup found.
|
||||||
const existing = React.useMemo(() => {
|
const existing = React.useMemo(() => {
|
||||||
if (!event?.uid) return null;
|
if (!event?.uid) return null;
|
||||||
return storeEvents.find((e) => e.uid === event.uid) ?? null;
|
return storeEvents.find((e) => e.uid === event.uid) ?? serverMatch;
|
||||||
}, [storeEvents, event?.uid]);
|
}, [storeEvents, event?.uid, serverMatch]);
|
||||||
|
|
||||||
const trust = React.useMemo(
|
const trust = React.useMemo(
|
||||||
() => (event ? getInvitationTrustAssessment(event, email, method) : null),
|
() => (event ? getInvitationTrustAssessment(event, email, method) : null),
|
||||||
@@ -173,20 +186,21 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) {
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setNotice(null);
|
setNotice(null);
|
||||||
try {
|
try {
|
||||||
// Make sure the event exists in a local calendar (dedupes by UID).
|
let target = existing;
|
||||||
if (!existing) await importEvents([event], targetCalendar.id);
|
if (!target) {
|
||||||
// Re-read from the store to get the server-assigned id + participant.
|
// Make sure the event exists in a local calendar (dedupes by UID),
|
||||||
const stored = useCalendarStore.getState().events.find((e) => e.uid === event.uid);
|
// then find it on the server for its id and participant: the store
|
||||||
const participant = stored
|
// never sees an event outside the loaded window.
|
||||||
? findParticipantByEmail(stored, currentUserEmails)
|
await importEvents([event], targetCalendar.id);
|
||||||
: me;
|
target = event.uid ? (await findEventsByUid(event.uid))[0] ?? null : null;
|
||||||
if (stored && participant) {
|
if (target) setServerMatch(target);
|
||||||
await rsvpEvent(stored.id, participant.id, status, buildReplyTo(event));
|
|
||||||
setRsvpStatus(status);
|
|
||||||
setNotice(t('calendar.invitation.response_sent', 'Response sent'));
|
|
||||||
} else {
|
|
||||||
setNotice(t('calendar.invitation.added', 'Added to calendar'));
|
|
||||||
}
|
}
|
||||||
|
const participant = target ? findParticipantByEmail(target, currentUserEmails) : null;
|
||||||
|
// Never claim success when no response went out.
|
||||||
|
if (!target || !participant) throw new Error('No event to respond to');
|
||||||
|
await rsvpEvent(target.id, participant.id, status, buildReplyTo(event), target);
|
||||||
|
setRsvpStatus(status);
|
||||||
|
setNotice(t('calendar.invitation.response_sent', 'Response sent'));
|
||||||
setState('done');
|
setState('done');
|
||||||
} catch {
|
} catch {
|
||||||
setNotice(t('calendar.invitation.response_error', 'Could not send your response'));
|
setNotice(t('calendar.invitation.response_error', 'Could not send your response'));
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ vi.mock('../../api/calendar', () => ({
|
|||||||
deleteEvents: vi.fn(),
|
deleteEvents: vi.fn(),
|
||||||
batchCreateEvents: vi.fn(),
|
batchCreateEvents: vi.fn(),
|
||||||
setDefaultCalendar: vi.fn(),
|
setDefaultCalendar: vi.fn(),
|
||||||
|
rsvpEvent: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||||
@@ -45,6 +46,7 @@ const mockUpdateEvent = calendarApi.updateEvent as ReturnType<typeof vi.fn>;
|
|||||||
const mockDeleteEvents = calendarApi.deleteEvents as ReturnType<typeof vi.fn>;
|
const mockDeleteEvents = calendarApi.deleteEvents as ReturnType<typeof vi.fn>;
|
||||||
const mockSetDefaultCalendar = calendarApi.setDefaultCalendar as ReturnType<typeof vi.fn>;
|
const mockSetDefaultCalendar = calendarApi.setDefaultCalendar as ReturnType<typeof vi.fn>;
|
||||||
const mockScan = calendarApi.scanCalendarObjects as ReturnType<typeof vi.fn>;
|
const mockScan = calendarApi.scanCalendarObjects as ReturnType<typeof vi.fn>;
|
||||||
|
const mockRsvpEvent = calendarApi.rsvpEvent as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -639,6 +641,39 @@ describe('calendar-store', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('rsvpEvent', () => {
|
||||||
|
it('answers an event outside the loaded window that the caller hands over (B22)', async () => {
|
||||||
|
useCalendarStore.setState({ events: [] });
|
||||||
|
mockRsvpEvent.mockResolvedValue(undefined);
|
||||||
|
const found = { id: 'ev9', uid: 'inv@example.com', start: '2027-01-10T09:00:00' } as any;
|
||||||
|
|
||||||
|
await useCalendarStore.getState().rsvpEvent(
|
||||||
|
'ev9', 'p1', 'accepted', { imip: 'mailto:boss@example.com' }, found,
|
||||||
|
);
|
||||||
|
|
||||||
|
// No organizer on the event: repaired from the invitation's replyTo.
|
||||||
|
expect(mockRsvpEvent).toHaveBeenCalledWith(
|
||||||
|
'ev9', 'p1', 'accepted', 'mailto:boss@example.com', undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers the loaded copy of the event', async () => {
|
||||||
|
useCalendarStore.setState({
|
||||||
|
events: [{
|
||||||
|
id: 'acc-2:ev1', originalId: 'ev1', accountId: 'acc-2',
|
||||||
|
organizerCalendarAddress: 'mailto:boss@example.com',
|
||||||
|
participants: { p1: { participationStatus: 'needs-action' } },
|
||||||
|
} as any],
|
||||||
|
});
|
||||||
|
mockRsvpEvent.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await useCalendarStore.getState().rsvpEvent('acc-2:ev1', 'p1', 'declined', { imip: 'mailto:x@example.com' });
|
||||||
|
|
||||||
|
expect(mockRsvpEvent).toHaveBeenCalledWith('ev1', 'p1', 'declined', undefined, 'acc-2');
|
||||||
|
expect(useCalendarStore.getState().events[0].participants?.p1.participationStatus).toBe('declined');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('toggleTaskComplete', () => {
|
describe('toggleTaskComplete', () => {
|
||||||
it('completes a task without sending progressUpdated, which Stalwart rejects (#958)', async () => {
|
it('completes a task without sending progressUpdated, which Stalwart rejects (#958)', async () => {
|
||||||
useCalendarStore.setState({
|
useCalendarStore.setState({
|
||||||
|
|||||||
@@ -190,6 +190,8 @@ export interface CalendarState {
|
|||||||
participantId: string,
|
participantId: string,
|
||||||
status: 'accepted' | 'declined' | 'tentative',
|
status: 'accepted' | 'declined' | 'tentative',
|
||||||
replyTo?: Record<string, string> | null,
|
replyTo?: Record<string, string> | null,
|
||||||
|
// The event itself, for one that isn't in the loaded window.
|
||||||
|
event?: CalendarEvent,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
importEvents: (events: Partial<CalendarEvent>[], calendarId: string) => Promise<number>;
|
importEvents: (events: Partial<CalendarEvent>[], calendarId: string) => Promise<number>;
|
||||||
createCalendar: (name: string, color?: string, description?: string) => Promise<Calendar>;
|
createCalendar: (name: string, color?: string, description?: string) => Promise<Calendar>;
|
||||||
@@ -561,13 +563,15 @@ export const useCalendarStore = create<CalendarState>()(
|
|||||||
return mapServerEventToStoreEvent(master, get().calendars, event.accountId);
|
return mapServerEventToStoreEvent(master, get().calendars, event.accountId);
|
||||||
},
|
},
|
||||||
|
|
||||||
rsvpEvent: async (eventId, participantId, status, replyTo) => {
|
rsvpEvent: async (eventId, participantId, status, replyTo, event) => {
|
||||||
// JMAP participant ids are opaque strings (they can contain @, ., :, /);
|
// JMAP participant ids are opaque strings (they can contain @, ., :, /);
|
||||||
// the api layer RFC 6901-escapes them, so only reject empty values.
|
// the api layer RFC 6901-escapes them, so only reject empty values.
|
||||||
if (!participantId) {
|
if (!participantId) {
|
||||||
throw new Error('Invalid participant ID');
|
throw new Error('Invalid participant ID');
|
||||||
}
|
}
|
||||||
const storeEvent = get().events.find((e) => e.id === eventId);
|
// An event outside the loaded window (an invitation looked up by UID)
|
||||||
|
// isn't in the store; the caller hands it over instead.
|
||||||
|
const storeEvent = get().events.find((e) => e.id === eventId) ?? event;
|
||||||
const realId = storeEvent?.originalId || eventId;
|
const realId = storeEvent?.originalId || eventId;
|
||||||
// Repair events that are missing the organizer (e.g. imported ones) so
|
// Repair events that are missing the organizer (e.g. imported ones) so
|
||||||
// Stalwart can route the REPLY; never touch an existing one.
|
// Stalwart can route the REPLY; never touch an existing one.
|
||||||
|
|||||||
Reference in New Issue
Block a user