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.

By the Reclario team · For engineers and QA building scheduling into a product

What's in this guide

  1. Why calendar flows are hard to test
  2. The protocol, briefly and correctly
  3. What "end-to-end" actually means here
  4. Four approaches and their trade-offs
  5. Doing it with Reclario
  6. Bugs this catches
  7. FAQ

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

METHODSent byMeans
REQUESTOrganizerHere is an event; you are invited. Also used to send updates to an existing event.
REPLYAttendeeMy answer, carried in PARTSTAT: ACCEPTED, DECLINED, or TENTATIVE.
CANCELOrganizerThis event (or occurrence) is off. Remove it.
COUNTERAttendeeNot 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

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.

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.

ApproachFidelityAutomatableReal 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.

AddressWhat comes back
interview+accept@yourteam.reclar.ioMETHOD:REPLY with PARTSTAT=ACCEPTED
interview+decline@yourteam.reclar.ioMETHOD:REPLY with PARTSTAT=DECLINED
interview+maybe@yourteam.reclar.io (or +tentative)METHOD:REPLY with PARTSTAT=TENTATIVE
interview+counter@yourteam.reclar.ioMETHOD: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

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.

Start free  Calendar invite testing →