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.

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
  3. What end-to-end means for invites
  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

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

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.

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

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.

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.

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

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, 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

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.

Start your 14-day trial  Calendar invite testing →