fix: dedupe scheduling emails, Stalwart-compatible calendar filters

This commit is contained in:
Linus Rath
2026-06-11 18:24:43 +02:00
parent a27a5be3dd
commit be58cee989
5 changed files with 438 additions and 267 deletions
@@ -323,6 +323,89 @@ describe('expandRecurringEvents', () => {
});
});
// -----------------------------------------------------------------------
// excludedRecurrenceRules (EXRULE)
// -----------------------------------------------------------------------
describe('excludedRecurrenceRules', () => {
it('removes occurrences generated by excluded rules', () => {
const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
excludedRecurrenceRules: [{
'@type': 'RecurrenceRule',
frequency: 'weekly',
byDay: [{ day: 'tu' }],
} as any],
});
// Jan 6-12 2025: Mon-Sun, Tuesday Jan 7 excluded
const result = expand(event, '2025-01-06T00:00:00', '2025-01-13T00:00:00');
const days = result.map(e => e.start.substring(0, 10));
expect(days).not.toContain('2025-01-07');
expect(days).toContain('2025-01-06');
expect(days).toContain('2025-01-08');
expect(result).toHaveLength(6);
});
});
// -----------------------------------------------------------------------
// Long-running series
// -----------------------------------------------------------------------
describe('long-running series', () => {
it('expands a daily series started years before the visible range', () => {
const event = makeEvent({
start: '2022-01-03T09:00:00',
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
});
const result = expand(event, '2025-06-02T00:00:00', '2025-06-05T00:00:00');
expect(starts(result)).toEqual([
'2025-06-02T09:00:00',
'2025-06-03T09:00:00',
'2025-06-04T09:00:00',
]);
});
it('expands a weekly series started years before the visible range', () => {
const event = makeEvent({
start: '2020-01-06T09:00:00', // Monday
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any],
});
const result = expand(event, '2025-06-01T00:00:00', '2025-06-30T00:00:00');
const days = result.map(e => e.start.substring(0, 10));
expect(days).toEqual(['2025-06-02', '2025-06-09', '2025-06-16', '2025-06-23']);
});
it('still respects count for old series (no fast-forward shortcut)', () => {
const event = makeEvent({
start: '2025-01-06T09:00:00',
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily', count: 5 } as any],
});
// Range far after the 5 occurrences ran out
const result = expand(event, '2025-06-01T00:00:00', '2025-06-30T00:00:00');
expect(result).toHaveLength(0);
});
});
// -----------------------------------------------------------------------
// Event-timezone DST handling
// -----------------------------------------------------------------------
describe('event timezone DST handling', () => {
it('keeps wall time across a DST transition in the event timezone', () => {
// America/New_York: EST (UTC-5) until 2025-03-09, EDT (UTC-4) after
const event = makeEvent({
start: '2025-03-03T10:00:00',
timeZone: 'America/New_York',
utcStart: '2025-03-03T15:00:00Z',
utcEnd: '2025-03-03T16:00:00Z',
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any],
} as Partial<CalendarEvent>);
const result = expand(event, '2025-03-03T00:00:00', '2025-03-17T00:00:00');
const utcStarts = result.map(e => (e as any).utcStart);
expect(utcStarts[0]).toBe('2025-03-03T15:00:00.000Z'); // EST: 10:00 -5
expect(utcStarts[1]).toBe('2025-03-10T14:00:00.000Z'); // EDT: 10:00 -4
const utcEnds = result.map(e => (e as any).utcEnd);
expect(utcEnds[1]).toBe('2025-03-10T15:00:00.000Z');
});
});
// -----------------------------------------------------------------------
// All-day events
// -----------------------------------------------------------------------