Skip to content

Commit c041043

Browse files
plcclaude
andcommitted
Add outbound calendar invites, RSVP replies, and Postmark webhook logging
- Send METHOD:REQUEST .ics invite emails via Postmark when events are created/updated with attendees (fire-and-forget, graceful degradation) - Send METHOD:REPLY .ics emails when agents respond to inbound invites - Track invite_sent, reply_sent, ical_sequence on events to prevent dupes - Add Postmark webhook endpoint for deliverability debugging (bounces, etc.) - Set trust proxy for correct rate limiting behind Fly's reverse proxy - Improved [outbound] logging with Postmark message IDs and error codes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d12e93e commit c041043

File tree

14 files changed

+1112
-8
lines changed

14 files changed

+1112
-8
lines changed

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,7 @@ DATABASE_URL=postgres://plc:postgres@host.docker.internal:5432/caldave
1414

1515
# Database name
1616
DB_NAME=caldave
17+
18+
# Postmark server API token for outbound calendar invite/reply emails
19+
# Optional — if not set, outbound emails are silently skipped
20+
POSTMARK_SERVER_TOKEN=

CALDAVE_SPEC.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -505,7 +505,7 @@ Rate limit headers: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-R
505505
3. **Webhook logs** — Failed deliveries are queryable via `GET /calendars/:id/webhook-logs`.
506506
4. **Domain**`caldave.ai` for v1. Email addresses: `cal-abc123@caldave.ai`.
507507
5. **Event size limits** — 64KB for description, 16KB for metadata JSON.
508-
6. **Outbound email**Not in v1. Agents can accept/decline internally but organisers are not notified. Stubbed for v2.
508+
6. **Outbound email**Implemented. Creating/updating events with attendees sends METHOD:REQUEST invite emails via Postmark. Responding to inbound invites sends METHOD:REPLY emails to the organiser. Requires `POSTMARK_SERVER_TOKEN` env var; gracefully skipped if not set.
509509
7. **Calendar sharing** — Not in v1. Could add via a `calendar_shares` table later.
510510

511511
---
@@ -517,5 +517,3 @@ Rate limit headers: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-R
517517
- Attachments on events
518518
- Free/busy lookups
519519
- CalDAV protocol compliance
520-
- Outbound calendar invites (agent inviting humans)
521-
- Outbound email responses (notifying organisers of accept/decline)

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
77
## [Unreleased]
88

99
### Added
10+
- **Outbound calendar invites** — when an event is created or updated with attendees, CalDave sends METHOD:REQUEST iCal invite emails via Postmark. Invites include `.ics` attachments that work with Google Calendar, Outlook, and Apple Calendar. From address is the calendar's email so replies route back through the inbound webhook.
11+
- **Outbound RSVP replies** — when an agent responds to an inbound invite via `POST /respond`, CalDave sends a METHOD:REPLY iCal email back to the organiser with the agent's acceptance, decline, or tentative status.
12+
- **Graceful degradation** — if `POSTMARK_SERVER_TOKEN` is not set, outbound emails are silently skipped. All API endpoints continue to work normally.
13+
- **Outbound email tracking** — new `invite_sent`, `reply_sent`, and `ical_sequence` columns on events prevent duplicate sends and support proper iCal update semantics.
14+
- **`email_sent` in respond response**`POST /respond` now includes an `email_sent` boolean indicating whether a reply email was triggered.
1015
- **All-day events** — events can now be created with `all_day: true` and date-only `start`/`end` in `YYYY-MM-DD` format. End date is inclusive (e.g. `start: "2025-03-15", end: "2025-03-15"` = one-day event). Supported across the full stack: API CRUD, recurring events, inbound email detection (VALUE=DATE), iCal feeds (DTSTART;VALUE=DATE), plain text view, MCP tools, and documentation.
1116
- **`caldave-mcp` npm package** — standalone MCP server published as `caldave-mcp` on npm. Run with `npx caldave-mcp` with `CALDAVE_API_KEY` set.
1217

18+
- **Postmark webhook event logging** — new endpoint ingests Postmark delivery, bounce, spam, open, and click events into a `postmark_webhooks` table for email deliverability debugging. GET the same URL to view recent events.
19+
20+
### Fixed
21+
- **`trust proxy` for Fly.io** — set `app.set('trust proxy', 1)` so `express-rate-limit` correctly identifies clients behind Fly's reverse proxy. Fixes `ERR_ERL_UNEXPECTED_X_FORWARDED_FOR` validation errors on every cold start.
22+
- **Improved outbound email logging** — all outbound email operations now log with `[outbound]` prefix including Postmark message IDs on success and status codes on failure.
23+
1324
### Changed
1425
- **`rrule` accepted as alias for `recurrence`** — POST/PATCH event endpoints now accept either `rrule` or `recurrence` for the recurrence rule field. `rrule` is the RFC 5545 term and more intuitive for most users.
1526
- **Timezone in event list responses**`GET /events` and `GET /upcoming` now include a `timezone` field in the response envelope when the calendar has a timezone set, making it easier for agents to convert UTC times.

GOTCHAS.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,28 @@ This file documents confusing issues, mistakes, and lessons learned. When Claude
3434

3535
**Prevention**: When implementing DST-aware recurrence, expand RRULEs in the calendar's local timezone, then convert each occurrence to UTC for storage.
3636

37+
### Postmark sender signature requirement
38+
39+
**Date**: 2026-02-14
40+
41+
**Problem**: Outbound calendar invite emails fail to send because Postmark rejects the From address.
42+
43+
**Cause**: Postmark requires all sender addresses to be verified via a Sender Signature or domain-level verification. Calendar email addresses (`cal-xxx@invite.caldave.ai`) are auto-generated and not individually registered as sender signatures.
44+
45+
**Solution**: Verify the entire `invite.caldave.ai` domain in the Postmark account settings (not individual email addresses). This allows any `*@invite.caldave.ai` address to send.
46+
47+
**Prevention**: When setting up a new Postmark account or changing the `CALDAVE_EMAIL_DOMAIN`, always verify the domain in Postmark first.
48+
49+
### ical-generator requires organizer.name
50+
51+
**Date**: 2026-02-14
52+
53+
**Problem**: `ical-generator` v10 throws `'organizer.name' is empty!` when creating a VEVENT with an organizer that has no name.
54+
55+
**Cause**: The library validates that `organizer.name` is a non-empty string. Setting just `{ email: 'foo@bar.com' }` fails.
56+
57+
**Solution**: Always provide both `name` and `email` when setting the organizer. For REPLY .ics (where we may not know the organiser's name), use the email address as the name.
58+
59+
**Prevention**: Always pass `{ name: ..., email: ... }` to `ical-generator`'s organizer option.
60+
3761
<!-- Add new post-mortems above this line -->

SPEC.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ fly deploy
4343
| `POST /calendars/:id/events/:eid/respond` | Yes | Accept/decline invite |
4444
| `GET /feeds/:id.ics?token=TOKEN` | Feed token | iCal feed (subscribable) |
4545
| `POST /inbound/:token` | Token in URL | Inbound email webhook (per-calendar) |
46+
| `POST /hooks/pm-Mj7aXcGE23gCfnql` | No (obscure URL) | Postmark outbound event webhook (delivery, bounce, spam, etc.) |
47+
| `GET /hooks/pm-Mj7aXcGE23gCfnql` | No (obscure URL) | View recent Postmark webhook events (?limit=N&type=Bounce) |
4648

4749
Auth = `Authorization: Bearer <api_key>` (except feeds, which use `?token=` query param)
4850

@@ -56,6 +58,7 @@ src/
5658
├── lib/
5759
│ ├── ids.js — nanoid-based ID generation (agt_, cal_, evt_, inb_)
5860
│ ├── keys.js — SHA-256 API key hashing
61+
│ ├── outbound.js — Outbound email (iCal invite/reply generation, Postmark sending)
5962
│ └── recurrence.js — RRULE parsing, materialization, horizon management
6063
├── middleware/
6164
│ ├── auth.js — Bearer token auth
@@ -66,15 +69,16 @@ src/
6669
├── calendars.js — Calendar CRUD
6770
├── events.js — Event CRUD + upcoming + respond
6871
├── feeds.js — iCal feed generation
69-
└── inbound.js — Inbound email webhook (per-calendar token URL)
72+
├── inbound.js — Inbound email webhook (per-calendar token URL)
73+
└── postmark-webhooks.js — Postmark outbound event logging (deliverability debugging)
7074
scripts/
7175
├── init-db.sh — Creates DB if not exists (Docker)
7276
└── get-port.sh — Deterministic port from project name
7377
```
7478

7579
## Database Schema
7680

77-
Three tables: `agents`, `calendars`, `events`. Schema auto-created on startup.
81+
Five tables: `agents`, `calendars`, `events`, `postmark_webhooks`, `error_log`. Schema auto-created on startup.
7882

7983
See `CALDAVE_SPEC.md` for full column definitions.
8084

@@ -109,6 +113,7 @@ Events can be created with `all_day: true`. When set:
109113
| `DATABASE_URL` || Postgres connection string |
110114
| `DB_NAME` | `caldave` | Database name for init script |
111115
| `CALDAVE_DOMAIN` | `caldave.ai` | Domain for calendar email addresses |
116+
| `POSTMARK_SERVER_TOKEN` || Postmark API token for outbound emails (optional; if unset, outbound emails are silently skipped) |
112117

113118
## Auth Model
114119

docker-compose.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ services:
3535
- DATABASE_URL=postgres://plc:postgres@host.docker.internal:5432/${DB_NAME:-caldave}
3636
- PORT=${PORT:-3000}
3737
- INBOUND_WEBHOOK_SECRET=${INBOUND_WEBHOOK_SECRET:-}
38+
- POSTMARK_SERVER_TOKEN=${POSTMARK_SERVER_TOKEN:-}
3839
extra_hosts:
3940
- "host.docker.internal:host-gateway"
4041
depends_on:

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"nanoid": "^5.1.6",
2828
"node-ical": "^0.25.1",
2929
"pg": "^8.11.3",
30+
"postmark": "^4.0.5",
3031
"rrule": "^2.8.1",
3132
"zod": "^4.3.6"
3233
},

src/db.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,27 @@ async function initSchema() {
104104
-- Per-calendar AgentMail API key for fetching attachments
105105
ALTER TABLE calendars ADD COLUMN IF NOT EXISTS agentmail_api_key text;
106106
107+
-- Outbound email tracking
108+
ALTER TABLE events ADD COLUMN IF NOT EXISTS invite_sent boolean NOT NULL DEFAULT false;
109+
ALTER TABLE events ADD COLUMN IF NOT EXISTS reply_sent text;
110+
ALTER TABLE events ADD COLUMN IF NOT EXISTS ical_sequence integer NOT NULL DEFAULT 0;
111+
112+
-- Postmark webhook events for email deliverability debugging
113+
CREATE TABLE IF NOT EXISTS postmark_webhooks (
114+
id serial PRIMARY KEY,
115+
record_type text NOT NULL,
116+
message_id text,
117+
recipient text,
118+
tag text,
119+
error_code text,
120+
error_message text,
121+
payload jsonb,
122+
created_at timestamptz NOT NULL DEFAULT now()
123+
);
124+
125+
CREATE INDEX IF NOT EXISTS idx_postmark_webhooks_created
126+
ON postmark_webhooks (created_at DESC);
127+
107128
-- Error log for tracking API errors
108129
CREATE TABLE IF NOT EXISTS error_log (
109130
id serial PRIMARY KEY,

src/index.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,11 @@ const docsRouter = require('./routes/docs');
3030
const quickstartRouter = require('./routes/quickstart');
3131
const manRouter = require('./routes/man');
3232
const viewRouter = require('./routes/view');
33+
const postmarkWebhooksRouter = require('./routes/postmark-webhooks');
3334
const { extendAllHorizons, EXTEND_INTERVAL_MS } = require('./lib/recurrence');
3435

3536
const app = express();
37+
app.set('trust proxy', 1);
3638
const port = process.env.PORT || 3000;
3739

3840
// ---------------------------------------------------------------------------
@@ -169,6 +171,9 @@ app.use('/feeds', feedsRouter);
169171
// Inbound email webhook (no Bearer auth — token in URL authenticates)
170172
app.use('/inbound', inboundLimiter, inboundRouter);
171173

174+
// Postmark outbound event webhooks (no auth — obscure URL is the secret)
175+
app.use('/hooks/pm-Mj7aXcGE23gCfnql', postmarkWebhooksRouter);
176+
172177
// ---------------------------------------------------------------------------
173178
// Authenticated routes
174179
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)