From f5f86d19f87a58a280ba2850a2b4153a40d3966d Mon Sep 17 00:00:00 2001 From: shaunwarman Date: Sat, 23 Aug 2025 08:26:36 -0500 Subject: [PATCH 1/2] feat: add caldav vtodo / tasks support --- app/models/calendar-events.js | 29 ++++- app/models/calendars.js | 8 ++ app/views/faq/index.md | 134 +++++++++++++++++++++ caldav-server.js | 221 ++++++++++++++++++++++++++++++++-- 4 files changed, 380 insertions(+), 12 deletions(-) diff --git a/app/models/calendar-events.js b/app/models/calendar-events.js index 045d196cef..d2d6feef32 100644 --- a/app/models/calendar-events.js +++ b/app/models/calendar-events.js @@ -68,6 +68,13 @@ const CalendarEvents = new mongoose.Schema( required: true, index: true }, + + // Component type to differentiate between events and tasks + componentType: { + type: String, + enum: ['VEVENT', 'VTODO'], + index: true + }, /* sequence: String, transparency: { @@ -105,7 +112,9 @@ const CalendarEvents = new mongoose.Schema( recurrences: mongoose.Schema.Types.Mixed, status: { type: String, - enum: [null, 'TENTATIVE', 'CONFIRMED', 'CANCELLED'] + // VEVENT: TENTATIVE, CONFIRMED, CANCELLED + // VTODO: NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED + enum: [null, 'TENTATIVE', 'CONFIRMED', 'CANCELLED', 'NEEDS-ACTION', 'IN-PROCESS', 'COMPLETED'] }, // @@ -127,6 +136,7 @@ const CalendarEvents = new mongoose.Schema( // '770bc7b3-d4ec-4306-bbb0-a773e8206487': { type: 'VEVENT', params: [], end: 2024-01-27T20:43:19.700Z }, // vcalendar: { type: 'VCALENDAR' } // } + // Also supports VTODO components for task management if (!isSANB(ics)) return false; // safeguard in case library isn't working for some reason @@ -141,9 +151,20 @@ const CalendarEvents = new mongoose.Schema( if (!comp) throw new TypeError('ICAL.Component was not successful'); const vevent = comp.getFirstSubcomponent('vevent'); - - if (!vevent) - throw new TypeError('comp.getFirstSubcomponent was not successful'); + const vtodo = comp.getFirstSubcomponent('vtodo'); + + if (!vevent && !vtodo) + throw new TypeError('No valid VEVENT or VTODO component found'); + + // Auto-detect and set component type + if (vevent && !vtodo) { + this.componentType = 'VEVENT'; + } else if (vtodo && !vevent) { + this.componentType = 'VTODO'; + } else if (vevent && vtodo) { + // If both exist, prioritize VEVENT for backward compatibility + this.componentType = 'VEVENT'; + } return true; } diff --git a/app/models/calendars.js b/app/models/calendars.js index d1f5d667fd..b01c024e89 100644 --- a/app/models/calendars.js +++ b/app/models/calendars.js @@ -110,6 +110,14 @@ const Calendars = new mongoose.Schema( required: true, lowercase: true, validate: (v) => isURL(v, { require_tld: false }) // require_tld: config.env === 'production' + }, + + // Supported calendar component types + supportedComponents: { + type: [String], + enum: ['VEVENT', 'VTODO'], + default: ['VEVENT'], // Default to events only for backward compatibility + index: true } }, dummySchemaOptions diff --git a/app/views/faq/index.md b/app/views/faq/index.md index 41e2c5e882..1f5fb76061 100644 --- a/app/views/faq/index.md +++ b/app/views/faq/index.md @@ -42,6 +42,9 @@ * [How do I connect and configure my contacts](#how-do-i-connect-and-configure-my-contacts) * [How do I connect and configure my calendars](#how-do-i-connect-and-configure-my-calendars) * [How do I add more calendars and manage existing calendars](#how-do-i-add-more-calendars-and-manage-existing-calendars) + * [How do I connect and configure tasks and reminders](#how-do-i-connect-and-configure-tasks-and-reminders) + * [Why can't I create tasks in macOS Reminders](#why-cant-i-create-tasks-in-macos-reminders) + * [How do I set up Tasks.org on Android](#how-do-i-set-up-tasksorg-on-android) * [How do I set up SRS for Forward Email](#how-do-i-set-up-srs-for-forward-email) * [How do I set up MTA-STS for Forward Email](#how-do-i-set-up-mta-sts-for-forward-email) * [How do I add a profile picture to my email address](#how-do-i-add-a-profile-picture-to-my-email-address) @@ -51,6 +54,7 @@ * [Do you support receiving email with IMAP](#do-you-support-receiving-email-with-imap) * [Do you support POP3](#do-you-support-pop3) * [Do you support calendars (CalDAV)](#do-you-support-calendars-caldav) + * [Do you support tasks and reminders (CalDAV VTODO)](#do-you-support-tasks-and-reminders-caldav-vtodo) * [Do you support contacts (CardDAV)](#do-you-support-contacts-carddav) * [Do you support sending email with SMTP](#do-you-support-sending-email-with-smtp) * [Do you support OpenPGP/MIME, end-to-end encryption ("E2EE"), and Web Key Directory ("WKD")](#do-you-support-openpgpmime-end-to-end-encryption-e2ee-and-web-key-directory-wkd) @@ -1529,6 +1533,102 @@ If you'd like to add additional calendars, then just add a new calendar URL of: You can change a calendar's name and color after creation – just use your preferred calendar application (e.g. Apple Mail or [Thunderbird](https://thunderbird.net)). +### How do I connect and configure tasks and reminders + +**To configure tasks and reminders, use the same CalDAV URL as calendars:** `https://caldav.forwardemail.net` (or simply `caldav.forwardemail.net` if your client allows it) + +Tasks and reminders will automatically be separated from calendar events into their own "Reminders" or "Tasks" calendar collection. + +**Setup instructions by platform:** + +**macOS/iOS:** +1. Add a new CalDAV account in System Preferences > Internet Accounts (or Settings > Accounts on iOS) +2. Use `caldav.forwardemail.net` as the server +3. Enter your Forward Email alias and generated password +4. After setup, you'll see both "Calendar" and "Reminders" collections +5. Use the Reminders app to create and manage tasks + +**Android with Tasks.org:** +1. Install Tasks.org from Google Play Store or F-Droid +2. Go to Settings > Synchronization > Add Account > CalDAV +3. Enter server: `https://caldav.forwardemail.net` +4. Enter your Forward Email alias and generated password +5. Tasks.org will automatically discover your task calendars + +**Thunderbird:** +1. Install the Lightning add-on if not already installed +2. Create a new calendar with type "CalDAV" +3. Use URL: `https://caldav.forwardemail.net` +4. Enter your Forward Email credentials +5. Both events and tasks will be available in the calendar interface + +### Why can't I create tasks in macOS Reminders + +If you're having trouble creating tasks in macOS Reminders, try these troubleshooting steps: + +1. **Check account setup**: Ensure your CalDAV account is properly configured with `caldav.forwardemail.net` + +2. **Verify separate calendars**: You should see both "Calendar" and "Reminders" in your account. If you only see "Calendar", the task support may not be fully activated yet. + +3. **Refresh account**: Try removing and re-adding your CalDAV account in System Preferences > Internet Accounts + +4. **Check server connectivity**: Test that you can access `https://caldav.forwardemail.net` in your browser + +5. **Verify credentials**: Ensure you're using the correct alias email and generated password (not your account password) + +6. **Force sync**: In Reminders app, try creating a task and then manually refreshing the sync + +**Common issues:** +- **"Reminders calendar not found"**: The server may need a moment to create the Reminders collection on first access +- **Tasks not syncing**: Check that both devices are using the same CalDAV account credentials +- **Mixed content**: Ensure tasks are being created in the "Reminders" calendar, not the general "Calendar" + +### How do I set up Tasks.org on Android + +Tasks.org is a popular open-source task manager that works excellently with Forward Email's CalDAV task support. + +**Installation and Setup:** + +1. **Install Tasks.org**: + - From Google Play Store: [Tasks.org](https://play.google.com/store/apps/details?id=org.tasks) + - From F-Droid: [Tasks.org on F-Droid](https://f-droid.org/packages/org.tasks/) + +2. **Configure CalDAV sync**: + - Open Tasks.org + - Go to ☰ Menu > Settings > Synchronization + - Tap "Add Account" + - Select "CalDAV" + +3. **Enter Forward Email settings**: + - **Server URL**: `https://caldav.forwardemail.net` + - **Username**: Your Forward Email alias (e.g., `you@yourdomain.com`) + - **Password**: Your alias-specific generated password + - Tap "Add Account" + +4. **Account discovery**: + - Tasks.org will automatically discover your task calendars + - You should see your "Reminders" collection appear + - Tap "Subscribe" to enable sync for the task calendar + +5. **Test sync**: + - Create a test task in Tasks.org + - Check that it appears in other CalDAV clients (like macOS Reminders) + - Verify changes sync both ways + +**Features available:** +- ✅ Task creation and editing +- ✅ Due dates and reminders +- ✅ Task completion and status +- ✅ Priority levels +- ✅ Subtasks and task hierarchy +- ✅ Tags and categories +- ✅ Two-way sync with other CalDAV clients + +**Troubleshooting:** +- If no task calendars appear, try manually refreshing in Tasks.org settings +- Ensure you have at least one task created on the server (you can create one in macOS Reminders first) +- Check network connectivity to `caldav.forwardemail.net` + ### How do I set up SRS for Forward Email We automatically configure [Sender Rewriting Scheme](https://en.wikipedia.org/wiki/Sender_Rewriting_Scheme) ("SRS") – you do not need to do this yourself. @@ -1712,6 +1812,40 @@ It supports both IPv4 and IPv6 and is available over port `443` (HTTPS). In order to use calendar support, the **user** must be the email address of an alias that exists for the domain at My Account Domains – and the **password** must be an alias-specific generated password. +### Do you support tasks and reminders (CalDAV VTODO) + +Yes, as of [INSERT DATE] we have added CalDAV VTODO support for tasks and reminders. This uses the same server as our calendar support: `caldav.forwardemail.net`. + +Our CalDAV server supports both calendar events (VEVENT) and tasks (VTODO) components, with automatic separation into appropriate calendar collections: + +- **Calendar events** go into your main "Calendar" collection +- **Tasks/reminders** go into a separate "Reminders" collection + +**Supported task clients:** +- **macOS Reminders** - Full native support for task creation, editing, completion, and sync +- **iOS Reminders** - Full native support across all iOS devices +- **Tasks.org (Android)** - Popular open-source task manager with CalDAV sync +- **Thunderbird with Lightning** - Task support in desktop email client +- **Any CalDAV-compatible task manager** - Standard VTODO component support + +**Task features supported:** +- Task creation, editing, and deletion +- Due dates and start dates +- Task completion status (NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED) +- Task priority levels +- Recurring tasks +- Task descriptions and notes +- Multi-device synchronization + +The login credentials are the same as for calendar support: + +| Login | Example | Description | +| -------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Username | `user@example.com` | Email address of an alias that exists for the domain at My Account Domains. | +| Password | `************************` | Alias-specific generated password. | + +**Important:** Tasks and calendar events are kept in separate collections to ensure proper client compatibility, especially with Apple devices that expect dedicated task calendars. + ### Do you support contacts (CardDAV) Yes, as of June 12, 2025 we have added this feature. Our server is `carddav.forwardemail.net` and is also monitored on our status page. diff --git a/caldav-server.js b/caldav-server.js index f5e02cfad7..6b2d7fc99e 100644 --- a/caldav-server.js +++ b/caldav-server.js @@ -330,7 +330,8 @@ async function ensureDefaultCalendars(ctx) { // // create "Calendar" in localized string // - name: I18N_CALENDAR[ctx.locale] || ctx.translate('CALENDAR') + name: I18N_CALENDAR[ctx.locale] || ctx.translate('CALENDAR'), + supportedComponents: ['VEVENT'] // Events only for non-Apple devices }); // return early since Apple check is up next @@ -383,7 +384,8 @@ async function ensureDefaultCalendars(ctx) { ...calendarDefaults, calendarId: randomUUID(), color: '#0000FF', // blue - name: 'DEFAULT_CALENDAR_NAME' // Calendar + name: 'DEFAULT_CALENDAR_NAME', // Calendar + supportedComponents: ['VEVENT'] // Events only }), defaultTaskCalendar ? Promise.resolve(defaultTaskCalendar) @@ -391,7 +393,8 @@ async function ensureDefaultCalendars(ctx) { ...calendarDefaults, calendarId: randomUUID(), color: '#FF0000', // red - name: 'DEFAULT_TASK_CALENDAR_NAME' // Reminders + name: 'DEFAULT_TASK_CALENDAR_NAME', // Reminders + supportedComponents: ['VTODO'] // Tasks only }) ]); @@ -418,6 +421,37 @@ function bumpSyncToken(synctoken) { ); } +// Helper function to detect component type from ICS data +function getComponentType(icsData) { + try { + const parsed = ICAL.parse(icsData); + if (!parsed || parsed.length === 0) return null; + + const comp = new ICAL.Component(parsed); + if (!comp) return null; + + const vevent = comp.getFirstSubcomponent('vevent'); + const vtodo = comp.getFirstSubcomponent('vtodo'); + + if (vevent && !vtodo) return 'VEVENT'; + if (vtodo && !vevent) return 'VTODO'; + if (vevent && vtodo) return 'VEVENT'; // Prioritize VEVENT for mixed content + return null; + } catch { + return null; + } +} + +// Helper function to determine if calendar supports specific component type +function calendarSupportsComponent(calendar, componentType) { + if (!calendar || !calendar.supportedComponents) { + // Default behavior: support VEVENT for backward compatibility + return componentType === 'VEVENT'; + } + + return calendar.supportedComponents.includes(componentType); +} + // TODO: support SMS reminders for VALARM // @@ -902,7 +936,20 @@ class CalDAV extends API { const comp = new ICAL.Component(ICAL.parse(calendarEvent.ical)); if (!comp) throw new TypeError('Component not parsed'); - // safeguard in case more than one event + // Check if this is a VTODO (task) - tasks don't typically send email invitations + const vtodos = comp.getAllSubcomponents('vtodo'); + if ( + vtodos.length > 0 && + comp.getAllSubcomponents('vevent').length === 0 + ) { + // This is a task-only component, skip email sending + ctx.logger.debug( + 'Skipping email for VTODO component - tasks do not send invitations' + ); + return; + } + + // Handle VEVENT components (calendar events) const vevents = comp.getAllSubcomponents('vevent'); let vevent = vevents.find((vevent) => { const uid = vevent.getFirstPropertyValue('uid'); @@ -1363,6 +1410,22 @@ class CalDAV extends API { // if (calendar) return calendar; // safeguard + // Determine supported components based on calendar name/type + let supportedComponents = ['VEVENT']; // Default to events + + // Task/reminder calendars support VTODO + if ( + name === 'DEFAULT_TASK_CALENDAR_NAME' || + I18N_SET_REMINDERS.has(name) || + I18N_SET_TASKS.has(name) + ) { + supportedComponents = ['VTODO']; + } + // Mixed calendars could support both (future enhancement) + // else if (someCondition) { + // supportedComponents = ['VEVENT', 'VTODO']; + // } + return Calendars.create({ // db virtual helper instance: this, @@ -1380,7 +1443,8 @@ class CalDAV extends API { timezone: timezone || ctx.state.session.user.timezone, url: config.urls.web, readonly: false, - synctoken: `${config.urls.web}/ns/sync-token/1` + synctoken: `${config.urls.web}/ns/sync-token/1`, + supportedComponents }); } @@ -1709,13 +1773,17 @@ class CalDAV extends API { for (const event of events) { const comp = new ICAL.Component(ICAL.parse(event.ical)); const vevents = comp.getAllSubcomponents('vevent'); - if (vevents.length === 0) { - const err = new TypeError('Event missing VEVENT'); + const vtodos = comp.getAllSubcomponents('vtodo'); + + if (vevents.length === 0 && vtodos.length === 0) { + const err = new TypeError('Event missing VEVENT or VTODO component'); ctx.logger.error(err, { event, calendar }); continue; } let match = false; + + // Process VEVENT components for (const vevent of vevents) { let lines = []; // start = dtstart @@ -1811,6 +1879,104 @@ class CalDAV extends API { } } + // Process VTODO components (tasks) + if (!match) { + for (const vtodo of vtodos) { + let lines = []; + // For tasks, we use DUE instead of DTEND, and DTSTART might not exist + let dtstart = vtodo.getFirstPropertyValue('dtstart'); + let due = vtodo.getFirstPropertyValue('due'); + + // Convert to JS dates if they exist + dtstart = + dtstart && dtstart instanceof ICAL.Time ? dtstart.toJSDate() : null; + due = due && due instanceof ICAL.Time ? due.toJSDate() : null; + + // Collect recurrence rules for tasks (if any) + for (const key of ['rrule', 'exrule', 'exdate', 'rdate']) { + const properties = vtodo.getAllProperties(key); + for (const prop of properties) { + lines.push(prop.toICALString()); + } + } + + if (lines.length === 0) { + // Non-recurring task - check date ranges + // For tasks, we need to be more flexible with date matching + const taskStart = dtstart; + const taskEnd = due || dtstart; // Use due date as end, or start if no due date + + if ( + (!start || (taskEnd && start <= taskEnd)) && + (!end || (taskStart && end >= taskStart)) + ) { + match = true; + break; + } + + continue; + } + + // Handle recurring tasks (same logic as events) + let rruleSet; + try { + rruleSet = rrulestr(lines.join('\n')); + } catch (err) { + if (err.message.includes('Unsupported RFC prop EXDATE in EXDATE')) { + try { + lines = _.compact( + lines.map((line) => { + if (line.includes('EXDATE')) { + return isValidExdate(line) ? line : null; + } + + return line; + }) + ); + rruleSet = rrulestr(lines.join('\n')); + } catch (err) { + err.isCodeBug = true; + ctx.logger.fatal(err); + throw err; + } + } else { + err.isCodeBug = true; + ctx.logger.fatal(err); + throw err; + } + } + + // Check queried date range for recurring tasks + if (start && end) { + const dates = rruleSet.between(start, end, true); + if (dates.length > 0) { + match = true; + break; + } + + continue; + } + + if (start) { + const date = rruleSet.after(start, true); + if (date) { + match = true; + break; + } + + continue; + } + + if (end) { + const date = rruleSet.before(end, true); + if (date) { + match = true; + break; + } + } + } + } + if (match) filtered.push(event); } @@ -1878,6 +2044,18 @@ class CalDAV extends API { ctx.translateError('CALENDAR_DOES_NOT_EXIST') ); + // Check if calendar supports the component type being created + const componentType = getComponentType(ctx.request.body); + if (!componentType) { + throw Boom.badRequest('Invalid calendar component'); + } + + if (!calendarSupportsComponent(calendar, componentType)) { + throw Boom.methodNotAllowed( + `Calendar does not support ${componentType} components` + ); + } + // check if there is an event with same calendar ID already const exists = await CalendarEvents.findOne(this, ctx.state.session, { eventId, @@ -2005,6 +2183,18 @@ class CalDAV extends API { ctx.translateError('CALENDAR_DOES_NOT_EXIST') ); + // Check if calendar supports the component type being updated + const componentType = getComponentType(ctx.request.body); + if (!componentType) { + throw Boom.badRequest('Invalid calendar component'); + } + + if (!calendarSupportsComponent(calendar, componentType)) { + throw Boom.methodNotAllowed( + `Calendar does not support ${componentType} components` + ); + } + let e = await CalendarEvents.findOne(this, ctx.state.session, { eventId, calendar: calendar._id @@ -2310,10 +2500,13 @@ class CalDAV extends API { } } - // add all VEVENTS + // add all VEVENTS and VTODOS for (const event of events) { const eventComp = new ICAL.Component(ICAL.parse(event.ical)); const vevents = eventComp.getAllSubcomponents('vevent'); + const vtodos = eventComp.getAllSubcomponents('vtodo'); + + // Process VEVENT components for (const vevent of vevents) { // // NOTE: until this issue is resolved we have to manually remove these lines @@ -2325,6 +2518,18 @@ class CalDAV extends API { // add to main calendar comp.addSubcomponent(vevent); } + + // Process VTODO components + for (const vtodo of vtodos) { + // + // NOTE: clean up Mozilla-specific properties for tasks too + // + vtodo.removeAllProperties('X-MOZ-LASTACK'); + vtodo.removeAllProperties('X-MOZ-GENERATION'); + + // add to main calendar + comp.addSubcomponent(vtodo); + } } return comp.toString(); From 79ba6961fb1771bc9ef50fcbd79da0b28cb2514f Mon Sep 17 00:00:00 2001 From: shaunwarman Date: Mon, 25 Aug 2025 20:23:41 -0500 Subject: [PATCH 2/2] fix: add some tests and fixture vtodo data --- app/views/faq/index.md | 98 +++---- helpers/mongoose-to-sqlite.js | 4 +- test/caldav/data/vtodo-1.ics | 16 ++ test/caldav/data/vtodo-2.ics | 17 ++ test/caldav/data/vtodo-completed.ics | 18 ++ test/caldav/index.js | 381 ++++++++++++++++++++++++++- 6 files changed, 485 insertions(+), 49 deletions(-) create mode 100644 test/caldav/data/vtodo-1.ics create mode 100644 test/caldav/data/vtodo-2.ics create mode 100644 test/caldav/data/vtodo-completed.ics diff --git a/app/views/faq/index.md b/app/views/faq/index.md index 1f5fb76061..7570b24ef1 100644 --- a/app/views/faq/index.md +++ b/app/views/faq/index.md @@ -1542,6 +1542,7 @@ Tasks and reminders will automatically be separated from calendar events into th **Setup instructions by platform:** **macOS/iOS:** + 1. Add a new CalDAV account in System Preferences > Internet Accounts (or Settings > Accounts on iOS) 2. Use `caldav.forwardemail.net` as the server 3. Enter your Forward Email alias and generated password @@ -1549,13 +1550,15 @@ Tasks and reminders will automatically be separated from calendar events into th 5. Use the Reminders app to create and manage tasks **Android with Tasks.org:** + 1. Install Tasks.org from Google Play Store or F-Droid 2. Go to Settings > Synchronization > Add Account > CalDAV 3. Enter server: `https://caldav.forwardemail.net` -4. Enter your Forward Email alias and generated password +4. Enter your Forward Email alias and generated password 5. Tasks.org will automatically discover your task calendars **Thunderbird:** + 1. Install the Lightning add-on if not already installed 2. Create a new calendar with type "CalDAV" 3. Use URL: `https://caldav.forwardemail.net` @@ -1579,9 +1582,10 @@ If you're having trouble creating tasks in macOS Reminders, try these troublesho 6. **Force sync**: In Reminders app, try creating a task and then manually refreshing the sync **Common issues:** -- **"Reminders calendar not found"**: The server may need a moment to create the Reminders collection on first access -- **Tasks not syncing**: Check that both devices are using the same CalDAV account credentials -- **Mixed content**: Ensure tasks are being created in the "Reminders" calendar, not the general "Calendar" + +* **"Reminders calendar not found"**: The server may need a moment to create the Reminders collection on first access +* **Tasks not syncing**: Check that both devices are using the same CalDAV account credentials +* **Mixed content**: Ensure tasks are being created in the "Reminders" calendar, not the general "Calendar" ### How do I set up Tasks.org on Android @@ -1590,44 +1594,46 @@ Tasks.org is a popular open-source task manager that works excellently with Forw **Installation and Setup:** 1. **Install Tasks.org**: - - From Google Play Store: [Tasks.org](https://play.google.com/store/apps/details?id=org.tasks) - - From F-Droid: [Tasks.org on F-Droid](https://f-droid.org/packages/org.tasks/) + * From Google Play Store: [Tasks.org](https://play.google.com/store/apps/details?id=org.tasks) + * From F-Droid: [Tasks.org on F-Droid](https://f-droid.org/packages/org.tasks/) 2. **Configure CalDAV sync**: - - Open Tasks.org - - Go to ☰ Menu > Settings > Synchronization - - Tap "Add Account" - - Select "CalDAV" + * Open Tasks.org + * Go to ☰ Menu > Settings > Synchronization + * Tap "Add Account" + * Select "CalDAV" 3. **Enter Forward Email settings**: - - **Server URL**: `https://caldav.forwardemail.net` - - **Username**: Your Forward Email alias (e.g., `you@yourdomain.com`) - - **Password**: Your alias-specific generated password - - Tap "Add Account" + * **Server URL**: `https://caldav.forwardemail.net` + * **Username**: Your Forward Email alias (e.g., `you@yourdomain.com`) + * **Password**: Your alias-specific generated password + * Tap "Add Account" 4. **Account discovery**: - - Tasks.org will automatically discover your task calendars - - You should see your "Reminders" collection appear - - Tap "Subscribe" to enable sync for the task calendar + * Tasks.org will automatically discover your task calendars + * You should see your "Reminders" collection appear + * Tap "Subscribe" to enable sync for the task calendar 5. **Test sync**: - - Create a test task in Tasks.org - - Check that it appears in other CalDAV clients (like macOS Reminders) - - Verify changes sync both ways + * Create a test task in Tasks.org + * Check that it appears in other CalDAV clients (like macOS Reminders) + * Verify changes sync both ways **Features available:** -- ✅ Task creation and editing -- ✅ Due dates and reminders -- ✅ Task completion and status -- ✅ Priority levels -- ✅ Subtasks and task hierarchy -- ✅ Tags and categories -- ✅ Two-way sync with other CalDAV clients + +* ✅ Task creation and editing +* ✅ Due dates and reminders +* ✅ Task completion and status +* ✅ Priority levels +* ✅ Subtasks and task hierarchy +* ✅ Tags and categories +* ✅ Two-way sync with other CalDAV clients **Troubleshooting:** -- If no task calendars appear, try manually refreshing in Tasks.org settings -- Ensure you have at least one task created on the server (you can create one in macOS Reminders first) -- Check network connectivity to `caldav.forwardemail.net` + +* If no task calendars appear, try manually refreshing in Tasks.org settings +* Ensure you have at least one task created on the server (you can create one in macOS Reminders first) +* Check network connectivity to `caldav.forwardemail.net` ### How do I set up SRS for Forward Email @@ -1814,28 +1820,30 @@ In order to use calendar support, the **user** must be the email address of an a ### Do you support tasks and reminders (CalDAV VTODO) -Yes, as of [INSERT DATE] we have added CalDAV VTODO support for tasks and reminders. This uses the same server as our calendar support: `caldav.forwardemail.net`. +Yes, as of \[INSERT DATE] we have added CalDAV VTODO support for tasks and reminders. This uses the same server as our calendar support: `caldav.forwardemail.net`. Our CalDAV server supports both calendar events (VEVENT) and tasks (VTODO) components, with automatic separation into appropriate calendar collections: -- **Calendar events** go into your main "Calendar" collection -- **Tasks/reminders** go into a separate "Reminders" collection +* **Calendar events** go into your main "Calendar" collection +* **Tasks/reminders** go into a separate "Reminders" collection **Supported task clients:** -- **macOS Reminders** - Full native support for task creation, editing, completion, and sync -- **iOS Reminders** - Full native support across all iOS devices -- **Tasks.org (Android)** - Popular open-source task manager with CalDAV sync -- **Thunderbird with Lightning** - Task support in desktop email client -- **Any CalDAV-compatible task manager** - Standard VTODO component support + +* **macOS Reminders** - Full native support for task creation, editing, completion, and sync +* **iOS Reminders** - Full native support across all iOS devices +* **Tasks.org (Android)** - Popular open-source task manager with CalDAV sync +* **Thunderbird with Lightning** - Task support in desktop email client +* **Any CalDAV-compatible task manager** - Standard VTODO component support **Task features supported:** -- Task creation, editing, and deletion -- Due dates and start dates -- Task completion status (NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED) -- Task priority levels -- Recurring tasks -- Task descriptions and notes -- Multi-device synchronization + +* Task creation, editing, and deletion +* Due dates and start dates +* Task completion status (NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED) +* Task priority levels +* Recurring tasks +* Task descriptions and notes +* Multi-device synchronization The login credentials are the same as for calendar support: diff --git a/helpers/mongoose-to-sqlite.js b/helpers/mongoose-to-sqlite.js index e8bd16d6a0..6d955d9b1d 100644 --- a/helpers/mongoose-to-sqlite.js +++ b/helpers/mongoose-to-sqlite.js @@ -1217,7 +1217,7 @@ function parseSchema(Model, modelName = '') { // stored as JSON (Array) if (typeof obj?.options?.default !== 'undefined') { default_value = safeStringify(obj.options.default); - _default = `DEFAULT "${default_value}"`; + _default = `DEFAULT '${default_value}'`; } getter = (v) => recursivelyParse(v); @@ -1231,7 +1231,7 @@ function parseSchema(Model, modelName = '') { // stored as JSON (Object) if (typeof obj?.options?.default !== 'undefined') { default_value = safeStringify(obj.options.default); - _default = `DEFAULT "${default_value}"`; + _default = `DEFAULT '${default_value}'`; } getter = (v) => recursivelyParse(v); diff --git a/test/caldav/data/vtodo-1.ics b/test/caldav/data/vtodo-1.ics new file mode 100644 index 0000000000..2f565573e0 --- /dev/null +++ b/test/caldav/data/vtodo-1.ics @@ -0,0 +1,16 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Caldav test./tsdav test 1.0.0//EN +CALSCALE:GREGORIAN +BEGIN:VTODO +UID:todo-e5d0fab9-c366-453e-a430-88267fceade1 +DTSTAMP:20210401T090944Z +CREATED:20210401T090944Z +LAST-MODIFIED:20210401T090944Z +SUMMARY:Complete project documentation +DESCRIPTION:Review and update all project documentation including README files and API documentation +PRIORITY:1 +STATUS:NEEDS-ACTION +DUE:20210405T170000Z +END:VTODO +END:VCALENDAR \ No newline at end of file diff --git a/test/caldav/data/vtodo-2.ics b/test/caldav/data/vtodo-2.ics new file mode 100644 index 0000000000..fc1d3a423b --- /dev/null +++ b/test/caldav/data/vtodo-2.ics @@ -0,0 +1,17 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Caldav test./tsdav test 1.0.0//EN +CALSCALE:GREGORIAN +BEGIN:VTODO +UID:todo-a1b2c3d4-e5f6-7890-1234-56789abcdef0 +DTSTAMP:20210402T100000Z +CREATED:20210402T100000Z +LAST-MODIFIED:20210402T100000Z +SUMMARY:Fix CalDAV VTODO support +DESCRIPTION:Implement proper VTODO filtering and test coverage for task management +PRIORITY:2 +STATUS:IN-PROCESS +PERCENT-COMPLETE:50 +DUE:20210410T120000Z +END:VTODO +END:VCALENDAR \ No newline at end of file diff --git a/test/caldav/data/vtodo-completed.ics b/test/caldav/data/vtodo-completed.ics new file mode 100644 index 0000000000..b401e9bcea --- /dev/null +++ b/test/caldav/data/vtodo-completed.ics @@ -0,0 +1,18 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Caldav test./tsdav test 1.0.0//EN +CALSCALE:GREGORIAN +BEGIN:VTODO +UID:todo-completed-123456789 +DTSTAMP:20210403T140000Z +CREATED:20210403T140000Z +LAST-MODIFIED:20210403T150000Z +SUMMARY:Deploy CalDAV server +DESCRIPTION:Deploy and configure the CalDAV server for production use +PRIORITY:1 +STATUS:COMPLETED +PERCENT-COMPLETE:100 +COMPLETED:20210403T150000Z +DUE:20210403T120000Z +END:VTODO +END:VCALENDAR \ No newline at end of file diff --git a/test/caldav/index.js b/test/caldav/index.js index c50e836997..6539ed7209 100644 --- a/test/caldav/index.js +++ b/test/caldav/index.js @@ -240,6 +240,19 @@ test.beforeEach(async (t) => { headers: t.context.authHeaders }); + // Create a task calendar for VTODO testing + await makeCalendar({ + url: t.context.serverUrl, + props: { + [`${DAVNamespaceShort.DAV}:displayname`]: 'Tasks', + [`${DAVNamespaceShort.CALDAV}:calendar-timezone`]: 'America/Los_Angeles', + [`${DAVNamespaceShort.CALDAV}:calendar-description`]: + 'Task calendar for VTODO objects', + [`${DAVNamespaceShort.CALDAV}:supported-calendar-component-set`]: 'VTODO' + }, + headers: t.context.authHeaders + }); + t.context.calendars = await fetchCalendars({ account: t.context.account, headers: t.context.authHeaders @@ -314,7 +327,7 @@ test('fetchCalendars should be able to fetch calendars', async (t) => { account: t.context.account, headers: t.context.authHeaders }); - t.is(calendars.length, 1); + t.is(calendars.length, 2); } await makeCalendar({ @@ -333,7 +346,7 @@ test('fetchCalendars should be able to fetch calendars', async (t) => { account: t.context.account, headers: t.context.authHeaders }); - t.is(calendars.length, 2); + t.is(calendars.length, 3); t.true(calendars.every((c) => c.url.length > 0)); } }); @@ -914,3 +927,367 @@ test('deleteObject should be able to delete object', async (t) => { t.true(deleteResult.ok); }); + +// +// VTODO (Task) Tests +// + +test('fetchCalendarObjects should be able to fetch VTODO objects with custom filter', async (t) => { + const vtodoString = await fsp.readFile( + path.join(__dirname, 'data', 'vtodo-1.ics'), + 'utf8' + ); + + const calendars = await fetchCalendars({ + account: t.context.account, + headers: t.context.authHeaders + }); + + // Find a calendar that supports VTODO components (task calendar) + const taskCalendar = + calendars.find( + (cal) => + cal.displayName?.includes('Reminders') || + cal.displayName?.includes('Tasks') || + cal.displayName === 'Tasks' || + cal.supportedComponents?.includes('VTODO') + ) || calendars.find((cal) => cal.displayName === 'Tasks'); + + const objectUrl = new URL('vtodo-1.ics', taskCalendar.url).href; + + const createResult = await createObject({ + url: objectUrl, + data: vtodoString, + headers: { + 'content-type': 'text/calendar; charset=utf-8', + ...t.context.authHeaders + } + }); + + t.true(createResult.ok); + + // Create custom VTODO filter + const filters = [ + { + 'comp-filter': { + _attributes: { name: 'VCALENDAR' }, + 'comp-filter': { + _attributes: { name: 'VTODO' } + } + } + } + ]; + + const objects = await fetchCalendarObjects({ + calendar: taskCalendar, + headers: t.context.authHeaders, + filters + }); + + t.true(objects.length > 0); + const vtodoObject = objects.find((obj) => obj.url === objectUrl); + t.truthy(vtodoObject); + t.true(vtodoObject.data.includes('BEGIN:VTODO')); + t.true(vtodoObject.data.includes('SUMMARY:Complete project documentation')); + + const deleteResult = await deleteObject({ + url: objectUrl, + headers: t.context.authHeaders + }); + + t.true(deleteResult.ok); +}); + +test('createObject should be able to create VTODO object', async (t) => { + const vtodoString = await fsp.readFile( + path.join(__dirname, 'data', 'vtodo-2.ics'), + 'utf8' + ); + + const calendars = await fetchCalendars({ + account: t.context.account, + headers: t.context.authHeaders + }); + + // Find a calendar that supports VTODO components (task calendar) + const taskCalendar = + calendars.find( + (cal) => + cal.displayName?.includes('Reminders') || + cal.displayName?.includes('Tasks') || + cal.displayName === 'Tasks' || + cal.supportedComponents?.includes('VTODO') + ) || calendars.find((cal) => cal.displayName === 'Tasks'); + + const objectUrl = new URL('vtodo-test.ics', taskCalendar.url).href; + + const response = await createObject({ + url: objectUrl, + data: vtodoString, + headers: { + 'content-type': 'text/calendar; charset=utf-8', + ...t.context.authHeaders + } + }); + + t.true(response.ok); + + // Fetch the created VTODO + const filters = [ + { + 'comp-filter': { + _attributes: { name: 'VCALENDAR' }, + 'comp-filter': { + _attributes: { name: 'VTODO' } + } + } + } + ]; + + const [calendarObject] = await fetchCalendarObjects({ + calendar: taskCalendar, + objectUrls: [objectUrl], + headers: t.context.authHeaders, + filters + }); + + t.true(calendarObject.url.length > 0); + t.true(calendarObject.etag.length > 0); + t.true(calendarObject.data.includes('SUMMARY:Fix CalDAV VTODO support')); + t.true(calendarObject.data.includes('STATUS:IN-PROCESS')); + t.true(calendarObject.data.includes('PERCENT-COMPLETE:50')); + + const deleteResult = await deleteObject({ + url: objectUrl, + headers: t.context.authHeaders + }); + + t.true(deleteResult.ok); +}); + +test('updateObject should be able to update VTODO status and progress', async (t) => { + const vtodoString = await fsp.readFile( + path.join(__dirname, 'data', 'vtodo-2.ics'), + 'utf8' + ); + + const updatedVtodoString = await fsp.readFile( + path.join(__dirname, 'data', 'vtodo-completed.ics'), + 'utf8' + ); + + const calendars = await fetchCalendars({ + account: t.context.account, + headers: t.context.authHeaders + }); + + // Find a calendar that supports VTODO components (task calendar) + const taskCalendar = + calendars.find( + (cal) => + cal.displayName?.includes('Reminders') || + cal.displayName?.includes('Tasks') || + cal.displayName === 'Tasks' || + cal.supportedComponents?.includes('VTODO') + ) || calendars.find((cal) => cal.displayName === 'Tasks'); + + const objectUrl = new URL('vtodo-update.ics', taskCalendar.url).href; + + const createResult = await createObject({ + url: objectUrl, + data: vtodoString, + headers: { + 'content-type': 'text/calendar; charset=utf-8', + ...t.context.authHeaders + } + }); + + t.true(createResult.ok); + + // Get the created VTODO to obtain etag + const filters = [ + { + 'comp-filter': { + _attributes: { name: 'VCALENDAR' }, + 'comp-filter': { + _attributes: { name: 'VTODO' } + } + } + } + ]; + + const [calendarObject] = await fetchCalendarObjects({ + calendar: taskCalendar, + objectUrls: [objectUrl], + headers: t.context.authHeaders, + filters + }); + + // Update to completed status + const updateResult = await updateObject({ + url: objectUrl, + data: updatedVtodoString, + etag: calendarObject.etag, + headers: { + 'content-type': 'text/calendar; charset=utf-8', + ...t.context.authHeaders + } + }); + + t.true(updateResult.ok); + + // Verify the update + const result = await undici.fetch(objectUrl, { + headers: t.context.authHeaders + }); + + t.true(result.ok); + const text = await result.text(); + + t.true(text.includes('STATUS:COMPLETED')); + t.true(text.includes('PERCENT-COMPLETE:100')); + t.true(text.includes('COMPLETED:')); + + const deleteResult = await deleteObject({ + url: objectUrl, + headers: t.context.authHeaders + }); + + t.true(deleteResult.ok); +}); + +test('fetchCalendarObjects should be able to filter VTODO by status', async (t) => { + const calendars = await fetchCalendars({ + account: t.context.account, + headers: t.context.authHeaders + }); + + // Find a calendar that supports VTODO components (task calendar) + const taskCalendar = + calendars.find( + (cal) => + cal.displayName?.includes('Reminders') || + cal.displayName?.includes('Tasks') || + cal.displayName === 'Tasks' || + cal.supportedComponents?.includes('VTODO') + ) || calendars.find((cal) => cal.displayName === 'Tasks'); + + const objectUrl1 = new URL('vtodo-filter-1.ics', taskCalendar.url).href; + const objectUrl2 = new URL('vtodo-filter-2.ics', taskCalendar.url).href; + const objectUrl3 = new URL('vtodo-filter-3.ics', taskCalendar.url).href; + + // Filter for VTODO with NEEDS-ACTION status + const needsActionFilter = [ + { + 'comp-filter': { + _attributes: { name: 'VCALENDAR' }, + 'comp-filter': { + _attributes: { name: 'VTODO' }, + 'prop-filter': { + _attributes: { name: 'STATUS' }, + 'text-match': { + _attributes: {}, + _value: 'NEEDS-ACTION' + } + } + } + } + } + ]; + + const needsActionObjects = await fetchCalendarObjects({ + calendar: taskCalendar, + headers: t.context.authHeaders, + filters: needsActionFilter + }); + + // Should find the VTODO with NEEDS-ACTION status + const foundNeedsAction = needsActionObjects.some( + (obj) => + obj.data.includes('STATUS:NEEDS-ACTION') && + obj.data.includes('SUMMARY:Complete project documentation') + ); + t.true(foundNeedsAction); + + // Clean up + await deleteObject({ url: objectUrl1, headers: t.context.authHeaders }); + await deleteObject({ url: objectUrl2, headers: t.context.authHeaders }); + await deleteObject({ url: objectUrl3, headers: t.context.authHeaders }); +}); + +test('calendarMultiGet should be able to get multiple VTODO objects', async (t) => { + const vtodo1 = await fsp.readFile( + path.join(__dirname, 'data', 'vtodo-1.ics'), + 'utf8' + ); + const vtodo2 = await fsp.readFile( + path.join(__dirname, 'data', 'vtodo-2.ics'), + 'utf8' + ); + + const calendars = await fetchCalendars({ + account: t.context.account, + headers: t.context.authHeaders + }); + + // Find a calendar that supports VTODO components (task calendar) + const taskCalendar = + calendars.find( + (cal) => + cal.displayName?.includes('Reminders') || + cal.displayName?.includes('Tasks') || + cal.displayName === 'Tasks' || + cal.supportedComponents?.includes('VTODO') + ) || calendars.find((cal) => cal.displayName === 'Tasks'); + + const objectUrl1 = new URL('vtodo-multi-1.ics', taskCalendar.url).href; + const objectUrl2 = new URL('vtodo-multi-2.ics', taskCalendar.url).href; + + const response1 = await createObject({ + url: objectUrl1, + data: vtodo1, + headers: { + 'content-type': 'text/calendar; charset=utf-8', + ...t.context.authHeaders + } + }); + + const response2 = await createObject({ + url: objectUrl2, + data: vtodo2, + headers: { + 'content-type': 'text/calendar; charset=utf-8', + ...t.context.authHeaders + } + }); + + t.true(response1.ok); + t.true(response2.ok); + + const calendarObjects = await fetchCalendarObjects({ + calendar: taskCalendar, + headers: t.context.authHeaders + }); + + t.is(calendarObjects.length, 2); + + // Verify both VTODO objects are returned + const vtodoObjects = calendarObjects.filter( + (obj) => obj.data && obj.data.includes('BEGIN:VTODO') + ); + t.is(vtodoObjects.length, 2); + + // Verify specific content + const hasProjectDoc = vtodoObjects.some( + (obj) => obj.data && obj.data.includes('Complete project documentation') + ); + const hasCalDAVFix = vtodoObjects.some( + (obj) => obj.data && obj.data.includes('Fix CalDAV VTODO support') + ); + t.true(hasProjectDoc); + t.true(hasCalDAVFix); + + // Clean up + await deleteObject({ url: objectUrl1, headers: t.context.authHeaders }); + await deleteObject({ url: objectUrl2, headers: t.context.authHeaders }); +});