caldav-adapter 8.3.4 → 8.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "caldav-adapter",
3
3
  "description": "CalDAV server for Node.js and Koa. Modernized and maintained for Forward Email.",
4
- "version": "8.3.4",
4
+ "version": "8.3.5",
5
5
  "author": "Sanders DeNardi and Forward Email LLC",
6
6
  "contributors": [
7
7
  "Sanders DeNardi <sedenardi@gmail.com> (http://www.sandersdenardi.com/)",
@@ -12,6 +12,21 @@ module.exports = function (options) {
12
12
 
13
13
  return async function (ctx, events, calendar, children) {
14
14
  const eventActions = _.map(events, async (event) => {
15
+ //
16
+ // For deleted events without href, we cannot reliably construct the
17
+ // correct URL that the client originally used. Returning an incorrect
18
+ // URL causes Apple Calendar to fail with "Couldn't get a calendar item
19
+ // to remove" errors because the URL doesn't match its local cache.
20
+ //
21
+ // Skip these events in sync-collection responses. The client will
22
+ // eventually clean them up through other sync mechanisms.
23
+ //
24
+ // See: https://www.rfc-editor.org/rfc/rfc6578.html (sync-collection)
25
+ //
26
+ if (event.deleted_at && !event.href) {
27
+ return null;
28
+ }
29
+
15
30
  const misses = [];
16
31
  const propActions = _.map(children, async (child) => {
17
32
  return tags.getResponse({
@@ -65,6 +80,7 @@ module.exports = function (options) {
65
80
  return resp;
66
81
  });
67
82
  const responses = await Promise.all(eventActions);
68
- return { responses };
83
+ // Filter out null responses (deleted events without href)
84
+ return { responses: _.compact(responses) };
69
85
  };
70
86
  };
@@ -95,15 +95,17 @@ test('event-response uses event.href when available instead of constructing from
95
95
  t.is(responseObj['D:href'], '/cal/user/calendar/original@event.ics');
96
96
  });
97
97
 
98
- test('event-response returns 404 status for deleted events', async (t) => {
98
+ test('event-response returns 404 status for deleted events with href', async (t) => {
99
99
  const options = createMockOptions();
100
100
  const eventResponse = eventResponseFactory(options);
101
101
  const ctx = createMockCtx('/cal/user/calendar');
102
102
  const calendar = createMockCalendar();
103
103
 
104
+ // Deleted event WITH href - should be included in response
104
105
  const events = [
105
106
  {
106
107
  eventId: 'deleted-event',
108
+ href: '/cal/user/calendar/deleted-event.ics',
107
109
  deleted_at: new Date(),
108
110
  ical: 'BEGIN:VCALENDAR\nEND:VCALENDAR'
109
111
  }
@@ -125,6 +127,30 @@ test('event-response returns 404 status for deleted events', async (t) => {
125
127
  t.true(responseObj['D:status'].includes('404'));
126
128
  });
127
129
 
130
+ test('event-response skips deleted events without href', async (t) => {
131
+ const options = createMockOptions();
132
+ const eventResponse = eventResponseFactory(options);
133
+ const ctx = createMockCtx('/cal/user/calendar');
134
+ const calendar = createMockCalendar();
135
+
136
+ // Deleted event WITHOUT href - should be skipped to prevent Apple sync errors
137
+ const events = [
138
+ {
139
+ eventId: 'deleted-event-no-href',
140
+ deleted_at: new Date(),
141
+ ical: 'BEGIN:VCALENDAR\nEND:VCALENDAR'
142
+ }
143
+ ];
144
+
145
+ const children = [];
146
+
147
+ const result = await eventResponse(ctx, events, calendar, children);
148
+
149
+ t.truthy(result.responses);
150
+ // Should be empty - deleted events without href are skipped
151
+ t.is(result.responses.length, 0);
152
+ });
153
+
128
154
  test('event-response uses href for deleted events when available (critical for sync)', async (t) => {
129
155
  const options = createMockOptions();
130
156
  const eventResponse = eventResponseFactory(options);
@@ -199,6 +225,7 @@ test('event-response handles multiple events correctly', async (t) => {
199
225
  },
200
226
  {
201
227
  eventId: 'event3',
228
+ href: '/cal/user/calendar/event3.ics',
202
229
  deleted_at: new Date(),
203
230
  ical: 'BEGIN:VCALENDAR\nEND:VCALENDAR'
204
231
  }
@@ -217,11 +244,55 @@ test('event-response handles multiple events correctly', async (t) => {
217
244
  // Event 2: uses href
218
245
  t.is(result.responses[1]['D:href'], '/cal/user/calendar/custom-path.ics');
219
246
 
220
- // Event 3: deleted, uses eventId (no href)
247
+ // Event 3: deleted with href
221
248
  t.is(result.responses[2]['D:href'], '/cal/user/calendar/event3.ics');
222
249
  t.true(result.responses[2]['D:status'].includes('404'));
223
250
  });
224
251
 
252
+ test('event-response handles multiple events with mixed deleted states', async (t) => {
253
+ const options = createMockOptions();
254
+ const eventResponse = eventResponseFactory(options);
255
+ const ctx = createMockCtx('/cal/user/calendar');
256
+ const calendar = createMockCalendar();
257
+
258
+ // Mix of events: active, deleted with href, deleted without href
259
+ const events = [
260
+ {
261
+ eventId: 'active-event',
262
+ ical: 'BEGIN:VCALENDAR\nEND:VCALENDAR'
263
+ },
264
+ {
265
+ eventId: 'deleted-with-href',
266
+ href: '/cal/user/calendar/deleted-with-href.ics',
267
+ deleted_at: new Date(),
268
+ ical: 'BEGIN:VCALENDAR\nEND:VCALENDAR'
269
+ },
270
+ {
271
+ eventId: 'deleted-no-href',
272
+ deleted_at: new Date(),
273
+ ical: 'BEGIN:VCALENDAR\nEND:VCALENDAR'
274
+ }
275
+ ];
276
+
277
+ const children = [];
278
+
279
+ const result = await eventResponse(ctx, events, calendar, children);
280
+
281
+ t.truthy(result.responses);
282
+ // Only 2 responses - deleted without href is skipped
283
+ t.is(result.responses.length, 2);
284
+
285
+ // Active event
286
+ t.is(result.responses[0]['D:href'], '/cal/user/calendar/active-event.ics');
287
+
288
+ // Deleted event with href
289
+ t.is(
290
+ result.responses[1]['D:href'],
291
+ '/cal/user/calendar/deleted-with-href.ics'
292
+ );
293
+ t.true(result.responses[1]['D:status'].includes('404'));
294
+ });
295
+
225
296
  test('event-response handles email-like eventId with @ symbol', async (t) => {
226
297
  const options = createMockOptions();
227
298
  const eventResponse = eventResponseFactory(options);