Guide
How to test calendar invites end-to-end (ICS, iTIP, and RSVP flows)
Your product sends an invite, and the attendee's calendar client sends something back: an accept, a decline, sometimes a proposal for a different time. That reply traffic, plus the reschedules and cancellations that follow it, is where scheduling features tend to break. This guide covers the protocol and how to exercise each of those paths in CI.
What's in this guide
Why calendar flows are hard to test
Testing ordinary email is mostly a solved problem. You stand up a fake SMTP sink, capture what your app sent, assert on the subject line and the reset-password link, and move on. It's easy because that kind of email is one-directional: your product sends, and nothing is expected to answer.
Calendar invites don't work that way. An invite opens a conversation. The organizer sends METHOD:REQUEST; a person in Gmail, Outlook or Apple Calendar clicks Accept; their client emails a METHOD:REPLY back to the organizer address; your product parses the reply and updates a row somewhere (interview confirmed, booking held, room released). You can fake the first message. You can't fake the answer, because it comes from software you don't control, on the far side of a mail hop you don't control either.
In practice, teams test the send and stop there. When replies get tested at all, it's with a spreadsheet of throwaway Gmail accounts, a QA engineer with fifteen browser tabs, and a manual script along the lines of "log in as candidate3, decline the 2pm, check the ATS." That works. It also doesn't run on pull requests, so it runs roughly never, and it won't be running on the day someone changes the scheduling code.
The result is predictable. The happy path (send the invite, someone accepts) gets good coverage and the rest gets very little. The rest is where the bugs are: reschedules, cancellations, tentative responses, counter-proposals, replies that arrive four days late, an attendee accepting an invite that was already withdrawn. Those are the paths where the state machine gets subtle. "Candidate declines and proposes Thursday instead" is one sentence in a ticket and about six protocol messages on the wire.
The problem in one sentence: to test a scheduling feature properly you need a counterparty that behaves like a real calendar client, answers on demand, answers the same way every time, and doesn't need a person to click anything. The rest of this guide is about how to get one.
The protocol, briefly
Two specifications matter here, and much of the confusion around calendar invites comes from mixing them up.
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 the rest. It describes what a calendar object looks like and says almost nothing about who sends it 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 moves forward. The email part is a third spec. When people say "calendar invites work over email," they mean iMIP (RFC 6047), which says to 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'll 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. |
RFC 5546 defines others (ADD, REFRESH, DECLINECOUNTER, PUBLISH), but the four above cover nearly every scheduling flow a product needs, and mainstream clients support the rest unevenly.
The properties that decide whether it works
UID: the identity of the event for its whole lifecycle. The original invite, every update, the cancellation, and every attendee reply must carry the same UID. It's the most important field in the exchange, and regenerating it is the 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, a well-behaved client is entitled to ignore it, and many do.DTSTAMP: when this iCalendar object was created. It's required and always in UTC. Don't confuse it withDTSTARTor withSEQUENCE.DTSTAMPdistinguishes messages;SEQUENCEdistinguishes revisions.DTSTART/DTEND: the event window. It comes in three forms, and the difference matters a lot: UTC (20260714T150000Z), a local time with aTZIDparameter that references aVTIMEZONEcomponent in the same object, or a floating local time with neither. Floating time means "3pm wherever the viewer is," which a scheduling product almost never wants.DTENDis exclusive.ORGANIZER: amailto:URI, and the address replies are sent to. If it isn't a mailbox you receive mail 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 move, cancel, or reply to a single occurrence, you send aVEVENTwith the sameUIDplus aRECURRENCE-IDsaying which occurrence. OmittingRECURRENCE-IDwhen you meant one occurrence cancels the whole series. That bug reaches production more often than it should.
A correct minimal REQUEST
Lines on the wire end in CRLF, and lines longer than 75 octets must be folded. The example uses UTC timestamps on purpose. As soon as you use TZID, RFC 5545 requires a matching VTIMEZONE component in the same object, so a "minimal" example with a TZID 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 travels in 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, what a client does is effectively undefined.
The corresponding REPLY
The UID is unchanged, and so is SEQUENCE, because a reply answers a specific revision and doesn't create a new one. The ATTENDEE line is narrowed to the person responding and carries the new PARTSTAT. The DTSTAMP is fresh.
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.
Client quirks to know before you write assertions. Google Calendar and Microsoft Outlook/Exchange both speak iTIP over email, but neither is a pure implementation. Exchange runs its own internal scheduling protocol and translates at the boundary, so a reply that passes through Exchange may lose properties or have them normalized. Outlook's "propose new time" produces a real COUNTER from desktop clients, but not every Outlook surface or mailbox configuration offers it. Google's web client has historically offered no counter-proposal at all, only accept, decline, and maybe. Several clients also drop a reply without telling anyone if RSVP=TRUE was never set on that attendee. Make your parser forgiving on input and strict on output.
What end-to-end means for invites
For a scheduling feature, end-to-end means the loop closes. Your product sends a message over real mail transport, something that behaves like a calendar client answers it, and your product ingests the answer and changes state. Anything less is testing your own serializer against your own parser.
Here's the coverage checklist. If a test can drive each of these, the scheduling feature is tested.
- Accept. The happy path: the invite goes out,
PARTSTAT=ACCEPTEDcomes back, and your UI shows it as confirmed. - Decline. Check that the slot is released, the recruiter is notified, and the interviewer's hold is dropped.
- Tentative. A third state, neither yes nor no. Plenty of products have no UI for it and quietly treat it as accepted.
- Counter / propose new time. A
COUNTERarrives with a differentDTSTART. Whether your product surfaces it or reschedules automatically, make sure it doesn't get dropped on the floor. - Organizer cancels. You send
METHOD:CANCELwith the sameUIDand a bumpedSEQUENCE. Check that the event disappears for the attendee and doesn't leave a ghost behind. - Time change with a
SEQUENCEbump. This is the reschedule path. Verify that the update replaces the original instead of duplicating it, and that earlier accepts go back 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. Decide which one wins, then test it.
- Double-booking. Two invites with overlapping windows for the same attendee. Your availability logic should notice.
- Timezone and DST boundaries. An event that crosses a DST transition in the attendee's zone, and one where organizer and attendee are in zones that change on different dates. The US and EU shift on different weekends, and that gap reliably produces bugs.
- All-day vs timed. An all-day event uses
DTSTART;VALUE=DATE:20260716, with no time component and no timezone. Products that assume a datetime everywhere fall over here. - Recurring series vs single occurrence. Decline the third occurrence of a weekly series with
RECURRENCE-IDand confirm the others survive. - Address that doesn't exist yet. A candidate invited before their account was provisioned, an address with a plus tag, a long generated address. Delivery and parsing both have to hold up.
A lot of that list is reschedule and cancel paths. That's where the state machine has the most edges and the least test coverage.
Four approaches and their trade-offs
This comparison includes where our own product falls short.
| Approach | Fidelity | Automatable | Real weakness |
|---|---|---|---|
| Real Google / Outlook test accounts | Highest: it's the real client | Barely | Someone has to click. Accounts get flagged, MFA-locked, and rate-limited. You can't provision one per test case, and the setup 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 another client renders and answers your invite. If your code has the bug, your mock usually has it too. |
| Outbound SMTP sandbox (Mailtrap and similar) | Good for outbound inspection | Yes, for assertions on what you sent | Nothing answers. It captures the REQUEST well, and the conversation stops there. It's very good at keeping staging mail from escaping, but it isn't a counterparty. |
| Receiving catch-all with programmable auto-RSVP | Real SMTP delivery, real iTIP replies | Yes | It isn't a calendar client. It doesn't render your invite the way Outlook does, so pure rendering bugs still need a human eye. |
Disclosure: Reclario is the fourth row, and we make it. We built it because the third row kept falling short for scheduling work. Weigh the weakness column when you decide. A broader survey of the category, including tools we don't sell, is in email testing tools compared.
A good setup uses three of these together: 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 visual checks before a release. The mistake is relying on only the first or only the third.
Doing it with Reclario
Reclario is a receiving harness. You claim a private catch-all domain such as yourteam.reclar.io, and every address on it can receive mail straight away. You don't set up individual addresses or do any DNS work on your side. Mail sent there is stored where you can inspect it, and calendar invites can be answered automatically.
To be clear about the boundaries, Reclario is not a calendar server. It doesn't host calendars, book rooms, resolve free/busy, or originate events. It receives your product's invites and plays the attendee's side of iTIP. That side is the part that's hard to get anywhere else, and you already have the rest.
Tagged addresses drive the response
Plus-tags on the local part select the behavior, so a test decides how its counterparty responds by choosing an address. There's no configuration call and no fixture to set up.
| 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, so you can test races against a reply that arrives at human speed |
Tags can be combined, and you can set a domain-level default so untagged addresses still respond the way you choose. The reply keeps the original UID and SEQUENCE, copies the organizer line unchanged, and includes any VTIMEZONE blocks from your invite as they were. A reply to a TZID-based event is therefore still a valid, self-contained iCalendar object and doesn't fall back to floating time.
In the web UI an invite shows up as an RSVP card with 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 delayed reply is pending you can send it now or override the response by hand. When a test fails, the card is where you see what the harness decided to do.
For the narrower, product-level version of this page, see 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 goes to whatever you set as ORGANIZER. That has to be a mailbox you can read, because it's where your assertion will look. The simplest choice is another address on the same Reclario domain: the reply lands in Reclario, and the test reads it over the API.
// npm i nodemailer
const nodemailer = require("nodemailer");
const UID = `interview-${Date.now()}@acme.example`;
const ORGANIZER = "recruiting@yourteam.reclar.io";
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); });
For the reschedule path, call buildRequest again with the same uid, sequence: 1, and a new start time. For cancellation, use the same uid with sequence: 2, METHOD:CANCEL, and STATUS:CANCELLED. Those three sends cover the whole lifecycle.
Wiring it into CI
An automated calendar test sends the invite, polls until the reply shows up in the organizer's mailbox (or until your own application state flips), and then asserts. To read mailboxes from code, you use a Reclario API key, sent as an Authorization bearer token or an X-API-Key header. There are also per-domain webhooks if you'd rather be pushed than poll. Take the current endpoint shapes from the in-app API docs instead of hardcoding them 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 talks to Reclario. It polls the mailbox, picks the message whose text/calendar part carries your UID, and parses out METHOD and PARTSTAT. Put a +delay tag on the address to widen that window on purpose and check that your UI handles an answer arriving long after the user navigated away.
Keep the UID as your test's correlation key. Generate it in the test instead of letting the app generate it and fishing it out afterwards. Every assertion in the chain can then match on it directly, and parallel runs are safe because no two tests share an 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. The client saw the same revision number and correctly ignored what looked like a duplicate. Nothing errors, and you can only see it from the receiving side.UIDregenerated on reschedule. A new UID turns the update into a separate event, so the attendee now has two interviews on their calendar and no way to tell which one is real. The usual cause is a "send invite" helper that builds the ICS from scratch and is called by both the create and update paths.- Replies never parsed. The attendee accepts, and the dashboard sits on "awaiting response" forever. Either 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. It looks right in the developer's own timezone and shows up an hour off for half your users, and only for part of the year, which is why it gets through 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 cost you user trust faster than almost any other scheduling bug.- Counter-proposals dropped. The attendee proposes Thursday. Your parser sees a
METHODit doesn't recognize, logs a warning nobody reads, and the candidate never hears back. You can't see this one from your own side, so 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 (add a number, like +counter30, to set the shift in minutes) and a real COUNTER goes back 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 (14-day free trial, card required), send an invite to anything+decline@yourteam.reclar.io with another address on that domain as the organizer, and watch the RSVP land there. You don't need DNS changes or a pool of test accounts to look after.