Guide
How to test calendar invites end-to-end (ICS, iTIP, and RSVP flows)
Your product sends an invite. Then what? The interesting half of a scheduling feature is everything that comes back — accepts, declines, counter-proposals, reschedules, cancellations. This is a working engineer's guide to exercising all of it, on purpose, in CI.
What's in this guide
Why calendar flows are hard to test
Email testing is a solved-ish problem. You can stand up a fake SMTP sink, capture what your app sent, assert on the subject line and the reset-password link, and move on. The asymmetry that makes it easy is that email is one-directional: your product speaks, and nothing is expected to answer.
Calendar invites break that assumption. An invite is the opening move in a conversation. The organizer sends METHOD:REQUEST; a real human, sitting in Gmail or Outlook or Apple Calendar, clicks Accept; their client emails a METHOD:REPLY back to the organizer address; your product parses that reply and updates a row somewhere — interview confirmed, booking held, room released. You can fake the first message. You cannot fake the answer, because the answer is produced by software you do not control, on the other side of a mail hop you also do not control.
So what happens in practice is that teams test the send and stop. The classic setup is a spreadsheet of throwaway Gmail accounts, a QA engineer with fifteen browser tabs, and a manual script that reads "log in as candidate3, decline the 2pm, check the ATS." It works. It does not run on every pull request, so it runs roughly never, and it certainly does not run at the moment the scheduling code changes.
The consequence is predictable: the happy path (send invite, someone accepts) is well covered, and everything else is not. Reschedules, cancellations, tentative responses, counter-proposals, replies that arrive four days late, an attendee who accepts an invite that was already withdrawn — these are the paths where the bugs actually live, because they are the paths where state machines get subtle. "Candidate declines and proposes Thursday instead" is one sentence in a ticket and about six protocol messages in reality.
The core problem in one line: testing a scheduling feature properly requires a counterparty that behaves like a real calendar client, on demand, deterministically, without a human. Everything below is about how to get one.
The protocol, briefly and correctly
Two specifications matter, and conflating them is the source of a lot of confusion.
iCalendar (RFC 5545) is the format — the BEGIN:VCALENDAR text serialization with its properties, value types, and folding rules. It defines VEVENT, DTSTART, RRULE, and so on. It describes what a calendar object looks like. It says almost nothing about who sends what to whom.
iTIP — the iCalendar Transport-Independent Interoperability Protocol (RFC 5546) — is the workflow layer on top. It defines the METHOD property and the state machine: who may send which method, what the recipient is supposed to do with it, and how a scheduling exchange progresses. When people say "calendar invites work over email," the email binding specifically is iMIP (RFC 6047), which says: put the iCalendar object in a text/calendar MIME part, carry the method in the content-type parameter, and send it as a normal message.
The four methods you will actually implement
| METHOD | Sent by | Means |
|---|---|---|
REQUEST | Organizer | Here is an event; you are invited. Also used to send updates to an existing event. |
REPLY | Attendee | My answer, carried in PARTSTAT: ACCEPTED, DECLINED, or TENTATIVE. |
CANCEL | Organizer | This event (or occurrence) is off. Remove it. |
COUNTER | Attendee | Not that time — how about this one. Carries the proposed DTSTART/DTEND. |
There are others in RFC 5546 — ADD, REFRESH, DECLINECOUNTER, PUBLISH — but the four above cover the overwhelming majority of product scheduling flows, and support for the rest across mainstream clients is uneven.
The properties that decide whether it works
UID— the identity of the event across its entire lifecycle. The original invite, every update, the cancellation, and every attendee reply must all carry the same UID. This is the single most important field in the whole exchange, and regenerating it is the single most common bug (see below).SEQUENCE— an integer revision counter, starting at 0, that the organizer increments on each material change (time, location, cancellation). Clients use it to order updates and to discard stale ones. If you send a reschedule without bumpingSEQUENCE, well-behaved clients are entitled to ignore it entirely — and many do.DTSTAMP— when this iCalendar object was created. Required. Always UTC. Not the same asDTSTART, and not the same asSEQUENCE:DTSTAMPdisambiguates messages,SEQUENCEdisambiguates revisions.DTSTART/DTEND— the event window. Three forms, and the distinction matters enormously: UTC (20260714T150000Z), a local time with aTZIDparameter referencing aVTIMEZONEcomponent in the same object, or a floating local time with neither. Floating time means "3pm wherever the viewer is," which is almost never what a scheduling product wants.DTENDis exclusive.ORGANIZER— amailto:URI. Replies are addressed here. If this does not match a mailbox you actually receive on, your product never learns the outcome.ATTENDEE— one per invitee, with parameters:ROLE(typicallyREQ-PARTICIPANT),RSVP=TRUEto request a response,CUTYPE,CNfor display name, andPARTSTATfor participation status (NEEDS-ACTIONon the way out, one ofACCEPTED/DECLINED/TENTATIVEon the way back).RRULEandRECURRENCE-ID— a recurring series is oneVEVENTwith anRRULE. To act on a single occurrence — move it, cancel it, reply to it — you send aVEVENTwith the sameUIDplus aRECURRENCE-IDidentifying which occurrence. OmittingRECURRENCE-IDwhen you meant one occurrence cancels the whole series. That bug ships to production more often than you would like.
A correct minimal REQUEST
Wire format is CRLF-terminated, and lines longer than 75 octets must be folded. This example uses UTC timestamps deliberately — the moment you use TZID, RFC 5545 requires a matching VTIMEZONE component in the same object, so a "minimal" TZID example would be incorrect.
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Acme Corp//Scheduling 1.0//EN
CALSCALE:GREGORIAN
METHOD:REQUEST
BEGIN:VEVENT
UID:interview-8f21c4-acme@acme.example
SEQUENCE:0
DTSTAMP:20260714T090000Z
DTSTART:20260716T150000Z
DTEND:20260716T154500Z
SUMMARY:Technical interview — Acme Corp
LOCATION:https://meet.example.com/abc-defg-hij
DESCRIPTION:45 minutes with the platform team.
ORGANIZER;CN=Acme Recruiting:mailto:recruiting@acme.example
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;
RSVP=TRUE;CN=Dana Reyes:mailto:dana@candidate.example
STATUS:CONFIRMED
END:VEVENT
END:VCALENDAR
Over email (iMIP), that object is carried as a multipart/alternative body — a text/plain part, usually a text/html part, and a text/calendar; charset=UTF-8; method=REQUEST part holding the ICS. Many senders also attach the same bytes as an application/ics attachment named invite.ics, because some clients only render the attachment. The method parameter on the content type should match the METHOD property inside; when they disagree, client behavior is undefined in practice.
The corresponding REPLY
Note what stays the same and what changes. Same UID. Same SEQUENCE — a reply answers a specific revision; it does not create a new one. The ATTENDEE line is narrowed to just the responding party, carrying the new PARTSTAT. Fresh DTSTAMP.
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Candidate Mail//Calendar//EN
CALSCALE:GREGORIAN
METHOD:REPLY
BEGIN:VEVENT
UID:interview-8f21c4-acme@acme.example
SEQUENCE:0
DTSTAMP:20260714T101322Z
DTSTART:20260716T150000Z
DTEND:20260716T154500Z
SUMMARY:Technical interview — Acme Corp
ORGANIZER;CN=Acme Recruiting:mailto:recruiting@acme.example
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=DECLINED;
CN=Dana Reyes:mailto:dana@candidate.example
END:VEVENT
END:VCALENDAR
Swap PARTSTAT=DECLINED for ACCEPTED or TENTATIVE and you have the other two answers. A COUNTER looks much the same but sets METHOD:COUNTER, typically PARTSTAT=TENTATIVE, and carries the proposed DTSTART/DTEND rather than the original.
Real-world wrinkles worth knowing before you write assertions. Google Calendar and Microsoft Outlook/Exchange both speak iTIP over email, but neither is a pure implementation. Exchange has its own internal scheduling protocol and translates at the boundary, so replies that traverse Exchange may lose or normalize properties. Outlook's "propose new time" produces a genuine COUNTER from desktop clients, but the feature is not uniformly available across every Outlook surface or every mailbox configuration. Google's web client historically has not offered counter-proposals at all — it exposes accept / decline / maybe. And several clients quietly drop a reply entirely if RSVP=TRUE was never set on that attendee. Write your parser to be forgiving on input and strict on output.
What "end-to-end" actually means here
"End-to-end" for a scheduling feature means the loop closes: your product sends a real message over real transport, a real client-equivalent produces a real response, and your product ingests it and changes state. Anything short of that is testing your own serializer against your own parser.
Here is the coverage checklist. If you can drive each of these from a test, your scheduling feature is genuinely tested.
- Accept — the happy path. Invite goes out,
PARTSTAT=ACCEPTEDcomes back, your UI shows confirmed. - Decline — does the slot get released? Does the recruiter get notified? Does the interviewer's hold get dropped?
- Tentative — a third state that is neither yes nor no. Plenty of products have no UI for it and silently coerce it to accepted.
- Counter / propose new time — a
COUNTERarrives with a differentDTSTART. Do you surface it, auto-reschedule, or drop it on the floor? - Organizer cancels — you send
METHOD:CANCELwith the sameUIDand a bumpedSEQUENCE. Does the event actually vanish for the attendee, or leave a ghost? - Time change with a
SEQUENCEbump — the reschedule path. Verify the update supersedes rather than duplicates, and that prior accepts are reset to needs-action where your product expects re-confirmation. - Late reply — an accept that lands after the meeting was moved, or after it was cancelled. What wins?
- Double-booking — two invites for overlapping windows on the same attendee. Does your availability logic notice?
- Timezone and DST boundaries — an event scheduled across a DST transition in the attendee's zone, and an event where organizer and attendee are in zones that transition on different dates (US and EU shift on different weekends — that gap is a reliable bug generator).
- All-day vs timed — an all-day event uses
DTSTART;VALUE=DATE:20260716with no time component and no timezone. Products that assume a datetime everywhere fall over here. - Recurring series vs single occurrence — decline occurrence three of a weekly series with
RECURRENCE-IDand confirm the other occurrences survive. - Address that does not exist yet — you invited a candidate before their account was provisioned, or an address with a plus tag, or a long generated address. Delivery and parsing both need to hold.
Note how many of those are reschedule and cancel paths. That is not an accident; it is where the state machine has the most edges and the least test coverage.
Four approaches and their trade-offs
Honest comparison, including where our own product falls short.
| Approach | Fidelity | Automatable | Real weakness |
|---|---|---|---|
| Real Google / Outlook test accounts | Highest — it is the actual client | Barely | Someone has to click. Accounts get flagged, MFA-locked, and rate-limited; provisioning one per test case does not scale, and the whole thing rots quietly. |
| Mocking the calendar layer in unit tests | None beyond your own code | Completely | Fast and worth having — but it proves nothing about delivery, MIME structure, or how a foreign client actually renders and answers your invite. Your mock agrees with your bug. |
| Outbound SMTP sandbox (Mailtrap and similar) | Good for outbound inspection | Yes, for assertions on what you sent | Nothing ever answers. It captures the REQUEST beautifully and the conversation stops there. Excellent at keeping staging mail from escaping; not a counterparty. |
| Receiving catch-all with programmable auto-RSVP | Real SMTP delivery, real iTIP replies | Yes | Not a calendar client — it does not render your invite the way Outlook does, so pure rendering bugs still need a human eye. |
Disclosure: Reclario is the fourth row, and it is ours. We built it because the third row kept not being enough for scheduling work. Use the honest weakness column above when deciding; a broader survey of the category, including tools we don't sell, is in email testing tools compared.
In practice a healthy setup uses three of these at once: mocks for the fast inner loop, a receiving harness for the automated end-to-end suite, and one or two real Google/Outlook accounts kept around for pre-release visual checks. The mistake is using only the first or only the third.
Doing it with Reclario
Reclario is a receiving harness. You claim a private catch-all domain — yourteam.reclar.io — and every address on it exists immediately, with no per-address setup and no DNS work on your side. Mail sent there is stored and inspectable, and calendar invites can be answered automatically.
To be clear about the boundaries: Reclario is not a calendar server. It does not host calendars, book rooms, resolve free/busy, or originate events. It receives your product's invites and speaks the attendee half of iTIP back at you. That is the piece that is hard to get elsewhere; everything else you already have.
Tagged addresses drive the response
The behavior is selected by plus-tags on the local part, so a test picks its counterparty's personality purely by choosing an address — no configuration call, no fixture setup.
| Address | What comes back |
|---|---|
interview+accept@yourteam.reclar.io | METHOD:REPLY with PARTSTAT=ACCEPTED |
interview+decline@yourteam.reclar.io | METHOD:REPLY with PARTSTAT=DECLINED |
interview+maybe@yourteam.reclar.io (or +tentative) | METHOD:REPLY with PARTSTAT=TENTATIVE |
interview+counter@yourteam.reclar.io | METHOD:COUNTER proposing a shifted time; +counter30 shifts by 30 minutes |
interview+decline+delay5@… | The same decline, sent five minutes later — human-like latency for testing races |
Tags compose, and a domain-level default can be set so untagged addresses still respond a chosen way. The reply preserves the original UID and SEQUENCE, carries the organizer line through unchanged, and passes any VTIMEZONE blocks from your invite straight through — so a reply to a TZID-based event stays a valid, self-contained iCalendar object rather than collapsing to floating time.
In the web UI, an invite renders as an RSVP card: When, Where, Organizer, the attendee list, the detected METHOD, and the auto-RSVP plan — what will be sent, when, and its current status (queued / sent / failed). If a delay is pending you can send it immediately, or override the response by hand. Useful when a test fails and you need to see what the harness actually decided.
The narrower, product-level version of this page is calendar invite testing.
Sending a REQUEST from Node
Nodemailer's icalEvent option builds the multipart/alternative body with the text/calendar part for you. Point to at a tagged address and the reply comes back to whatever you set as ORGANIZER — which must be a mailbox you can actually read, since that is where your assertion will look.
// npm i nodemailer
const nodemailer = require("nodemailer");
const UID = `interview-${Date.now()}@acme.example`;
const ORGANIZER = "recruiting@acme.example";
const ATTENDEE = "interview+decline+delay1@yourteam.reclar.io";
function buildRequest({ uid, sequence, start, end }) {
const stamp = (d) => new Date(d).toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
return [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//Acme Corp//Scheduling 1.0//EN",
"CALSCALE:GREGORIAN",
"METHOD:REQUEST",
"BEGIN:VEVENT",
`UID:${uid}`,
`SEQUENCE:${sequence}`,
`DTSTAMP:${stamp(Date.now())}`,
`DTSTART:${stamp(start)}`,
`DTEND:${stamp(end)}`,
"SUMMARY:Technical interview — Acme Corp",
`ORGANIZER;CN=Acme Recruiting:mailto:${ORGANIZER}`,
"ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;" +
`RSVP=TRUE;CN=Dana Reyes:mailto:${ATTENDEE}`,
"STATUS:CONFIRMED",
"END:VEVENT",
"END:VCALENDAR",
].join("\r\n");
}
async function main() {
const transport = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT || 587),
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
});
const start = Date.now() + 48 * 3600 * 1000;
const ics = buildRequest({ uid: UID, sequence: 0, start, end: start + 45 * 60 * 1000 });
await transport.sendMail({
from: `"Acme Recruiting" <${ORGANIZER}>`,
to: ATTENDEE,
subject: "Invitation: Technical interview — Thu Jul 16, 3:00 PM UTC",
text: "You're invited to a 45-minute technical interview.",
icalEvent: { method: "REQUEST", filename: "invite.ics", content: ics },
});
console.log("sent REQUEST", UID);
}
main().catch((e) => { console.error(e); process.exit(1); });
To test the reschedule path, call buildRequest again with the same uid, sequence: 1, and a new start time. To test cancellation, same uid, sequence: 2, METHOD:CANCEL, and STATUS:CANCELLED. That is the entire lifecycle, in three sends.
Wiring it into CI
The shape of an automated calendar test is: send the invite, then poll until the reply shows up in the organizer's mailbox (or until your own application state flips), then assert. Reclario exposes API keys — sent as an Authorization bearer token or an X-API-Key header — for reading mailboxes programmatically, plus per-domain webhooks if you would rather be pushed than poll. Check the in-app API docs for the current endpoint shapes rather than hardcoding from a blog post.
// tests/scheduling.spec.js — Playwright sketch
const { test, expect } = require("@playwright/test");
const { sendInvite } = require("./helpers/invite");
const { waitForReply } = require("./helpers/mailbox"); // polls the mailbox API
test("declined invite releases the interview slot", async ({ page }) => {
const uid = `interview-${Date.now()}@acme.example`;
// 1. Product sends a real invite to a decline-tagged address.
await sendInvite({ uid, to: "interview+decline@yourteam.reclar.io" });
// 2. Wait for the real iTIP REPLY to land in the organizer mailbox.
const reply = await waitForReply({ uid, timeoutMs: 60_000 });
expect(reply.method).toBe("REPLY");
expect(reply.partstat).toBe("DECLINED");
expect(reply.uid).toBe(uid); // identity preserved across the round trip
expect(reply.sequence).toBe(0); // a reply answers a revision, never bumps it
// 3. Assert the product reacted: the slot is bookable again.
await page.goto("/scheduling/slots");
await expect(page.getByTestId(`slot-${uid}`)).toHaveAttribute("data-state", "open");
});
waitForReply is the only piece that touches Reclario: poll the mailbox for messages, pick the one whose text/calendar part carries your UID, parse out METHOD and PARTSTAT. With a +delay tag on the address you can widen that window deliberately and prove your UI handles an answer that arrives long after the user navigated away.
Keep the UID as your test's correlation key. Generating it in the test — rather than letting the app generate it and fishing it out afterwards — makes every assertion in the chain trivially matchable, and makes parallel test runs safe because no two tests can collide on identity.
Bugs this catches
SEQUENCEnever bumped. The reschedule is sent, your logs say "update delivered," and the attendee's calendar still shows the old time — because the client saw the same revision number and correctly ignored a duplicate. Silent, and only visible from the receiving side.UIDregenerated on reschedule. A fresh UID makes the update a brand-new event, so the attendee now has two interviews on their calendar and no way to tell which is real. Classic symptom of building the ICS from scratch in a "send invite" helper that both the create and update paths call.- Replies never parsed. Everything works, the attendee accepts, and the dashboard sits on "awaiting response" forever — because nothing is reading the organizer mailbox, or the parser only looks at attachments and this client used the inline
text/calendarpart. - Floating time.
DTSTART:20260716T150000with noZand noTZID. Looks correct in the developer's own timezone, and shows up an hour off for half your users — and only during part of the year, which is why it survives review. CANCELnot handled. Either your product never sends it (the meeting is gone from your database and still on the attendee's calendar), or it never processes an inbound one. Ghost events erode trust faster than almost any other scheduling bug.- Counter-proposals dropped. The attendee proposes Thursday. Your parser sees a
METHODit does not recognize, logs a warning nobody reads, and the candidate never hears back. This one is invisible from your side by construction — the only way to find it is to have something send you a realCOUNTER. - Occurrence vs series confusion. Declining one instance of a recurring event cancels all of them, because
RECURRENCE-IDwas omitted. - Late replies overwriting current state. An accept for
SEQUENCE:0arrives after you moved the meeting toSEQUENCE:1, and your handler marks the new time as confirmed by someone who never saw it.
FAQ
What is the difference between ICS and iTIP?
ICS (iCalendar, RFC 5545) is the file format — the BEGIN:VCALENDAR text with its properties. iTIP (RFC 5546) is the workflow layer that defines METHOD values like REQUEST, REPLY, CANCEL, and COUNTER, and the rules for who may send what. iMIP (RFC 6047) is the binding that carries iTIP messages over email as text/calendar MIME parts.
Why is my calendar update being ignored by clients?
Almost always because SEQUENCE was not incremented. Clients treat an object with the same UID and the same or lower SEQUENCE as a duplicate of what they already have and discard it. Bump SEQUENCE on every material change — time, location, or cancellation — and keep the UID identical.
Can I test invite accept and decline flows without real Gmail or Outlook accounts?
Yes. Send the invite to an address on a harness that receives real SMTP mail and emits a real iTIP REPLY back to the organizer. That gives you genuine delivery and a genuine protocol response without a human clicking. You still want a real client account or two for visual rendering checks before a release, since a harness is not a calendar UI.
How do you test a counter-proposal (propose new time)?
You need something that will send you a METHOD:COUNTER with a different DTSTART from the one you offered, carrying the original UID. With Reclario, address the invite to a +counter tagged address — optionally +counter30 to control the shift in minutes — and a real COUNTER is returned to the organizer.
Does Reclario host calendars or book meetings?
No. Reclario is a test harness for the receiving side: it accepts mail on a private catch-all domain, stores and displays it, and can auto-respond to calendar invites with real iTIP replies. It does not host calendars, resolve free/busy, book rooms, or originate events.
Test a real invite in the next five minutes
Claim a private catch-all domain, send an invite to anything+decline@yourteam.reclar.io, and watch a real RSVP come back to your organizer address. No DNS setup, no test accounts to babysit.