UNPKG

caldav-adapter

Version:

CalDAV server for Node.js and Koa. Modernized and maintained for Forward Email.

379 lines (330 loc) 10.2 kB
/** * RFC 6638 Scheduling Extensions to CalDAV * Scheduling inbox/outbox routes handler * * @see https://tools.ietf.org/html/rfc6638 */ const { build, buildTag, href, multistatus, response, status } = require('../../common/x-build'); const { setMultistatusResponse, setOptions } = require('../../common/response'); const winston = require('../../common/winston'); const dav = 'DAV:'; const cal = 'urn:ietf:params:xml:ns:caldav'; module.exports = function (options) { const log = winston({ ...options, label: 'scheduling' }); /** * Handle POST to scheduling outbox for iTIP and free-busy queries * POST /cal/:principalId/outbox/ * * @see https://tools.ietf.org/html/rfc6638#section-3.2 */ async function postOutbox(ctx) { log.debug('POST outbox request', { url: ctx.url }); const { body } = ctx.request; if (!body) { ctx.status = 400; ctx.body = 'Missing request body'; return; } // Check if this is a free-busy query if (body.includes('VFREEBUSY') && body.includes('METHOD:REQUEST')) { return handleFreeBusyQuery(ctx, body); } // Handle iTIP scheduling request return handleItipRequest(ctx, body); } /** * Get free-busy data for a single attendee */ async function getFreeBusyForAttendee(ctx, attendee) { let scheduleStatus = '2.0'; // Success let freeBusyData = ''; try { freeBusyData = typeof options.data.getFreeBusy === 'function' ? await options.data.getFreeBusy(ctx, attendee) : generateEmptyFreeBusy(attendee); } catch (err) { log.warn('Error getting free-busy data', { attendee, error: err.message }); scheduleStatus = '3.7'; // Invalid calendar user } return { [buildTag(cal, 'response')]: { [buildTag(cal, 'recipient')]: href(`mailto:${attendee}`), [buildTag(cal, 'request-status')]: scheduleStatus, [buildTag(cal, 'calendar-data')]: freeBusyData } }; } /** * Handle free-busy query * @see https://tools.ietf.org/html/rfc6638#section-3.2.1 */ async function handleFreeBusyQuery(ctx, body) { log.debug('Processing free-busy query'); // Extract attendees from the request const attendeeMatches = body.match(/attendee[^:]*:mailto:([^\r\n]+)/gi) || []; const attendees = attendeeMatches .map((match) => { const email = match.match(/mailto:([^\r\n]+)/i); return email ? email[1].toLowerCase() : null; }) .filter(Boolean); if (attendees.length === 0) { ctx.status = 400; ctx.body = 'No attendees specified in free-busy query'; return; } // Build schedule-response for each attendee (in parallel) const responses = await Promise.all( attendees.map((attendee) => getFreeBusyForAttendee(ctx, attendee)) ); setMultistatusResponse(ctx); ctx.body = build( multistatus([ { [buildTag(cal, 'schedule-response')]: responses } ]) ); } /** * Send scheduling message to a single attendee */ async function sendMessageToAttendee(ctx, method, attendee, icalData) { let scheduleStatus = '1.1'; // Pending - message queued for delivery try { if (typeof options.data.sendSchedulingMessage === 'function') { await options.data.sendSchedulingMessage(ctx, { method, attendee, icalData }); scheduleStatus = '1.2'; // Delivered } } catch (err) { log.warn('Error sending scheduling message', { attendee, error: err.message }); scheduleStatus = '5.1'; // Could not complete delivery } return { [buildTag(cal, 'response')]: { [buildTag(cal, 'recipient')]: href(`mailto:${attendee}`), [buildTag(cal, 'request-status')]: scheduleStatus } }; } /** * Handle iTIP scheduling request (REQUEST, REPLY, CANCEL, etc.) * @see https://tools.ietf.org/html/rfc6638#section-3.2.2 */ async function handleItipRequest(ctx, body) { log.debug('Processing iTIP request'); // RFC 5545 Section 3.1: unfold long content lines before parsing // Folded lines start with CRLF followed by a single whitespace character const unfolded = body.replaceAll(/\r\n[ \t]/g, ''); // Extract METHOD from the iCalendar data const methodMatch = unfolded.match(/method:([a-z]+)/i); const method = methodMatch ? methodMatch[1].toUpperCase() : 'REQUEST'; // Extract attendees const attendeeMatches = unfolded.match(/attendee[^:]*:mailto:([^\r\n]+)/gi) || []; const attendees = attendeeMatches .map((match) => { const email = match.match(/mailto:([^\r\n]+)/i); return email ? email[1].toLowerCase() : null; }) .filter(Boolean); // Send messages in parallel const responses = await Promise.all( attendees.map((attendee) => sendMessageToAttendee(ctx, method, attendee, body) ) ); setMultistatusResponse(ctx); ctx.body = build( multistatus([ { [buildTag(cal, 'schedule-response')]: responses } ]) ); } /** * Handle PROPFIND on scheduling inbox * PROPFIND /cal/:principalId/inbox/ * * @see https://tools.ietf.org/html/rfc6638#section-2.2 */ async function propfindInbox(ctx) { log.debug('PROPFIND inbox request', { url: ctx.url }); const props = [ { [buildTag(dav, 'resourcetype')]: { [buildTag(dav, 'collection')]: '', [buildTag(cal, 'schedule-inbox')]: '' } }, { [buildTag(dav, 'displayname')]: 'Schedule Inbox' }, { [buildTag(cal, 'calendar-free-busy-set')]: '' }, { [buildTag(dav, 'current-user-privilege-set')]: { [buildTag(dav, 'privilege')]: [ { [buildTag(dav, 'read')]: '' }, { [buildTag(cal, 'schedule-deliver')]: '' } ] } } ]; setMultistatusResponse(ctx); ctx.body = build(multistatus([response(ctx.url, status[200], props)])); } /** * Handle GET on scheduling inboxbox - list scheduling messages * GET /cal/:principalId/inbox/ * * @see https://tools.ietf.org/html/rfc6638#section-2.2 */ async function getInbox(ctx) { log.debug('GET inbox request', { url: ctx.url }); // Return empty collection by default // Implementations can override via options.data.getSchedulingMessages let messages = []; try { if (typeof options.data.getSchedulingMessages === 'function') { messages = await options.data.getSchedulingMessages(ctx); } } catch (err) { log.warn('Error getting scheduling messages', { error: err.message }); } const responses = messages.map((msg) => { return response(msg.href, status[200], [ { [buildTag(dav, 'getetag')]: msg.etag }, { [buildTag(dav, 'getcontenttype')]: 'text/calendar; charset=utf-8' }, { [buildTag(cal, 'calendar-data')]: msg.icalData } ]); }); // Add collection response responses.unshift( response(ctx.url, status[200], [ { [buildTag(dav, 'resourcetype')]: { [buildTag(dav, 'collection')]: '', [buildTag(cal, 'schedule-inbox')]: '' } } ]) ); setMultistatusResponse(ctx); ctx.body = build(multistatus(responses)); } /** * Handle PROPFIND on scheduling outbox * PROPFIND /cal/:principalId/outbox/ * * @see https://tools.ietf.org/html/rfc6638#section-2.1 */ async function propfindOutbox(ctx) { log.debug('PROPFIND outbox request', { url: ctx.url }); const props = [ { [buildTag(dav, 'resourcetype')]: { [buildTag(dav, 'collection')]: '', [buildTag(cal, 'schedule-outbox')]: '' } }, { [buildTag(dav, 'displayname')]: 'Schedule Outbox' }, { [buildTag(dav, 'current-user-privilege-set')]: { [buildTag(dav, 'privilege')]: [ { [buildTag(dav, 'read')]: '' }, { [buildTag(cal, 'schedule-send')]: '' } ] } } ]; setMultistatusResponse(ctx); ctx.body = build(multistatus([response(ctx.url, status[200], props)])); } /** * Generate empty VFREEBUSY response */ function generateEmptyFreeBusy(attendee) { const now = new Date(); const dtstamp = now .toISOString() .replaceAll(/[-:]/g, '') .replace(/\.\d{3}/, ''); const uid = `freebusy-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; return [ 'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Forward Email//CalDAV Adapter//EN', 'METHOD:REPLY', 'BEGIN:VFREEBUSY', `DTSTAMP:${dtstamp}`, `UID:${uid}`, `ATTENDEE:mailto:${attendee}`, 'END:VFREEBUSY', 'END:VCALENDAR' ].join('\r\n'); } return { postOutbox, propfindInbox, getInbox, propfindOutbox, /** * Route handler for scheduling endpoints */ async route(ctx) { const method = ctx.method.toLowerCase(); // Use calendarId from route params for reliable inbox/outbox detection // instead of URL substring matching which can collide with calendar names const calId = ( (ctx.state.params && ctx.state.params.calendarId) || '' ).toLowerCase(); const isInbox = calId === 'inbox'; const isOutbox = calId === 'outbox'; if (method === 'options') { if (isOutbox) { setOptions(ctx, ['OPTIONS', 'POST', 'PROPFIND']); } else if (isInbox) { setOptions(ctx, ['OPTIONS', 'GET', 'PROPFIND', 'DELETE']); } return; } if (isOutbox) { if (method === 'post') { return postOutbox(ctx); } if (method === 'propfind') { return propfindOutbox(ctx); } } if (isInbox) { if (method === 'get') { return getInbox(ctx); } if (method === 'propfind') { return propfindInbox(ctx); } } ctx.status = 405; ctx.body = 'Method Not Allowed'; } }; };