Ergonomic iCalendar (RFC 5545) and iMIP (RFC 6047) for Go. Parse invites, build REQUEST/REPLY/CANCEL, expand RRULE recurrences, compute free/busy.
go-icalendar turns the low-level property bag of an .ics file into a flat,
render-ready Event struct, and provides the scheduling glue an email client
otherwise writes by hand: parsing invites, building outgoing REQUEST/CANCEL
messages, generating the fiddly iMIP replies Google Calendar and Outlook
require, expanding recurrence rules, and computing availability.
It was extracted from matcha's mail reader, where it powers the meeting-invite card and Accept / Decline / Tentative replies.
- Flat
Event. Summary, times, organizer, attendees, status, recurrence — all on one struct, no property lookups at the call site. - Parse one or many.
ParseICSfor the first VEVENT (what mail clients want),Parsefor the whole calendar. - Correct timestamps. UTC, floating,
TZID-qualified andVALUE=DATEall-day values — including the real-world quirk of aTZIDwrongly attached to a date-only value, which is ignored rather than silently shifting the day. - iMIP-correct replies.
GenerateRSVPreproduces exactly what schedulers expect:METHOD:REPLY, only the responding attendee, updatedPARTSTAT,RSVP=TRUE, freshDTSTAMP, and a preserved UID. - Build outgoing invites.
NewRequest/NewCancel/Event.Reply→Serialize()to RFC-compliant bytes. - A real recurrence engine. DAILY/WEEKLY/MONTHLY/YEARLY with INTERVAL,
COUNT/UNTIL, BYMONTH, BYMONTHDAY, BYDAY (ordinals like
2MO/-1FR) and BYSETPOS, plus RDATE/EXDATE merging. - Free/busy. Merge events (recurrences expanded) into busy intervals and the free gaps between them.
- Single dependency. Only
github.com/arran4/golang-ical.
go get github.com/floatpane/go-icalendarRequires Go 1.26+.
package main
import (
"fmt"
"log"
"os"
icalendar "github.com/floatpane/go-icalendar"
)
func main() {
data, err := os.ReadFile("invite.ics")
if err != nil {
log.Fatal(err)
}
ev, err := icalendar.ParseICS(data)
if err != nil {
log.Fatal(err)
}
fmt.Println(ev.Summary, ev.Start.Local(), "->", ev.End.Local())
for _, a := range ev.Attendees {
fmt.Printf(" %s (%s)\n", a.Email, a.PartStat)
}
}// response: "ACCEPTED", "DECLINED", "TENTATIVE"
reply, err := icalendar.GenerateRSVP(data, "me@example.com", "ACCEPTED")
// Send reply as text/calendar; method=REPLY back to the organizer.start := time.Date(2026, 5, 1, 9, 0, 0, 0, time.UTC)
ev := &icalendar.Event{
UID: "kickoff@example.com",
Summary: "Project kickoff",
Location: "Room 1",
Start: start,
End: start.Add(time.Hour),
Organizer: "me@example.com",
Attendees: []icalendar.Attendee{
{Email: "you@example.com", PartStat: icalendar.PartStatNeedsAction, RSVP: true},
},
}
ics, err := icalendar.NewRequest(ev).Serialize()
// Later — call it off (bumps SEQUENCE, sets STATUS:CANCELLED):
cancelICS, err := icalendar.NewCancel(ev).Serialize()ev, _ := icalendar.ParseICS(data)
for _, t := range ev.Occurrences(time.Now(), time.Now().AddDate(0, 1, 0), 0) {
fmt.Println(t.Local())
}
// Or work with a rule directly:
r, _ := icalendar.ParseRRule("FREQ=MONTHLY;BYDAY=-1FR") // last Friday monthly
times := r.Between(start, from, to, 0)busy, free := icalendar.FreeBusy(events, from, to)
for _, gap := range free {
if gap.Duration() >= 30*time.Minute {
fmt.Println("can meet at", gap.Start)
break
}
}| Part | Status |
|---|---|
FREQ (SECONDLY → YEARLY), INTERVAL, COUNT, UNTIL |
✅ |
BYMONTH, BYMONTHDAY (incl. negatives), BYDAY (incl. ordinals), BYSETPOS, WKST |
✅ |
BYWEEKNO, BYYEARDAY |
parsed, ignored during expansion |
Full API reference: pkg.go.dev/github.com/floatpane/go-icalendar
Guides and diagrams: see docs/.
| Project | Role |
|---|---|
| floatpane/matcha | Reference consumer — renders invites and sends RSVPs. |
| floatpane/go-secretbox | Sibling extraction — password-based encryption at rest. |
PRs welcome. See CONTRIBUTING.md.
Report vulnerabilities privately via SECURITY.md.
MIT. See LICENSE.