icalendar

package module
v0.0.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 1, 2026 License: MIT Imports: 7 Imported by: 0

README

go-icalendar

Ergonomic iCalendar (RFC 5545) and iMIP (RFC 6047) for Go. Parse invites, build REQUEST/REPLY/CANCEL, expand RRULE recurrences, compute free/busy.

Go Version Go Reference GitHub release (latest by date) CI

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.

Features

  • Flat Event. Summary, times, organizer, attendees, status, recurrence — all on one struct, no property lookups at the call site.
  • Parse one or many. ParseICS for the first VEVENT (what mail clients want), Parse for the whole calendar.
  • Correct timestamps. UTC, floating, TZID-qualified and VALUE=DATE all-day values — including the real-world quirk of a TZID wrongly attached to a date-only value, which is ignored rather than silently shifting the day.
  • iMIP-correct replies. GenerateRSVP reproduces exactly what schedulers expect: METHOD:REPLY, only the responding attendee, updated PARTSTAT, RSVP=TRUE, fresh DTSTAMP, and a preserved UID.
  • Build outgoing invites. NewRequest / NewCancel / Event.ReplySerialize() 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.

Install

go get github.com/floatpane/go-icalendar

Requires Go 1.26+.

Usage

Parse an invite
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)
    }
}
Reply (RSVP)
// response: "ACCEPTED", "DECLINED", "TENTATIVE"
reply, err := icalendar.GenerateRSVP(data, "me@example.com", "ACCEPTED")
// Send reply as text/calendar; method=REPLY back to the organizer.
Build and send an invite
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()
Expand a recurring event
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)
Compute free/busy
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
    }
}

Supported RRULE parts

Part Status
FREQ (SECONDLY → YEARLY), INTERVAL, COUNT, UNTIL
BYMONTH, BYMONTHDAY (incl. negatives), BYDAY (incl. ordinals), BYSETPOS, WKST
BYWEEKNO, BYYEARDAY parsed, ignored during expansion

Documentation

Full API reference: pkg.go.dev/github.com/floatpane/go-icalendar

Guides and diagrams: see docs/.

Sister projects

Project Role
floatpane/matcha Reference consumer — renders invites and sends RSVPs.
floatpane/go-secretbox Sibling extraction — password-based encryption at rest.

Contributing

PRs welcome. See CONTRIBUTING.md.

Security

Report vulnerabilities privately via SECURITY.md.

License

MIT. See LICENSE.

Documentation

Overview

Package icalendar is an ergonomic wrapper around the iCalendar (RFC 5545) and iMIP/iTIP (RFC 6047 / RFC 5546) formats for Go email clients and schedulers.

It turns the low-level property bag exposed by the underlying parser (github.com/arran4/golang-ical) into a flat, easy-to-render Event struct, and provides the glue most mail clients end up writing by hand:

The package name is icalendar and the import path is github.com/floatpane/go-icalendar.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func GenerateRSVP

func GenerateRSVP(originalData []byte, userEmail, response string) ([]byte, error)

GenerateRSVP turns a received invite (originalData) into an RFC 6047 (iMIP) reply on behalf of userEmail. response must be one of "ACCEPTED", "DECLINED" or "TENTATIVE" (the PartStatAccepted/PartStatDeclined/PartStatTentative values).

It reproduces exactly what Google Calendar and Outlook expect from a reply:

  • METHOD:REPLY at the calendar level;
  • only the responding attendee left in each VEVENT (all others removed);
  • that attendee's PARTSTAT set to response and RSVP=TRUE;
  • a fresh DTSTAMP.

Working from the original bytes (rather than a re-serialized Event) keeps the UID, SEQUENCE, recurrence and organizer of the invite byte-for-byte, which is what lets the organizer's calendar match the reply to the original event.

Types

type Attendee

type Attendee struct {
	Email    string   // address, without the "mailto:" scheme
	Name     string   // CN parameter, if any
	Role     string   // ROLE parameter (e.g. REQ-PARTICIPANT)
	PartStat PartStat // PARTSTAT parameter
	RSVP     bool     // RSVP parameter
}

Attendee is a single ATTENDEE on a VEVENT.

type Calendar

type Calendar struct {
	Method  string // METHOD (REQUEST, REPLY, CANCEL, ...)
	ProdID  string // PRODID
	Version string // VERSION (almost always "2.0")
	Events  []*Event
}

Calendar is a parsed (or constructed) VCALENDAR: a method, some bookkeeping headers, and the events it carries.

func NewCalendar

func NewCalendar() *Calendar

NewCalendar returns an empty PUBLISH calendar ready for Calendar.Add.

func NewCancel

func NewCancel(e *Event) *Calendar

NewCancel builds a METHOD:CANCEL calendar for e, marking it CANCELLED and bumping its SEQUENCE so recipients treat it as a newer update than the original invite (RFC 5546 §3.2.5).

func NewRequest

func NewRequest(e *Event) *Calendar

NewRequest builds a METHOD:REQUEST calendar inviting attendees to e — the message an organizer sends to schedule a meeting. The event's Status defaults to CONFIRMED when unset.

func Parse

func Parse(data []byte) (*Calendar, error)

Parse reads .ics data and returns every VEVENT it contains, along with the calendar-level method and headers. Unlike ParseICS it does not require any events to be present — an empty calendar yields a Calendar with no Events and a nil error.

func (*Calendar) Add

func (c *Calendar) Add(events ...*Event) *Calendar

Add appends events to the calendar and returns it, for chaining.

func (*Calendar) Serialize

func (c *Calendar) Serialize() ([]byte, error)

Serialize renders the calendar as RFC 5545 bytes (CRLF-folded). It fills in sensible defaults for an empty ProdID/Version, and stamps any event missing a DTSTAMP with the current time.

type Event

type Event struct {
	UID         string
	Summary     string // event title (SUMMARY)
	Description string
	Location    string
	Start       time.Time
	End         time.Time
	AllDay      bool // DTSTART/DTEND were VALUE=DATE (no time component)

	Organizer     string // organizer email
	OrganizerName string // organizer CN, if any
	Attendees     []Attendee

	Status     string // CONFIRMED, TENTATIVE, CANCELLED
	Method     string // REQUEST, REPLY, CANCEL (mirrors the calendar METHOD)
	Sequence   int    // SEQUENCE; bump on every update to an existing UID
	URL        string
	Categories []string

	Stamp    time.Time // DTSTAMP
	Created  time.Time // CREATED
	Modified time.Time // LAST-MODIFIED

	// Recurrence is the parsed RRULE, or nil for a one-off event.
	Recurrence *RRule
	// RDates and ExDates are explicit additional / excluded occurrence starts.
	RDates  []time.Time
	ExDates []time.Time
}

Event is a parsed (or hand-built) VEVENT, flattened for easy rendering.

Status and Method are kept as plain strings (rather than the typed Status and Method) because they are read straight from the wire and may carry values an older or non-conforming producer emitted; compare them against the typed constants with string conversion, e.g. ev.Status == string(StatusConfirmed).

func ParseICS

func ParseICS(data []byte) (*Event, error)

ParseICS extracts the first VEVENT from .ics data. It is the convenience entry point for mail clients, which overwhelmingly deal with single-event invites; use Parse when a calendar may hold several events.

func (*Event) Duration

func (e *Event) Duration() time.Duration

Duration is the wall-clock length of the event. It is zero when either endpoint is unset.

func (*Event) IsRecurring

func (e *Event) IsRecurring() bool

IsRecurring reports whether the event carries an RRULE or any RDATE.

func (*Event) Occurrences

func (e *Event) Occurrences(from, to time.Time, limit int) []time.Time

Occurrences returns the concrete start times of this event within the half-open window [from, to), merging the RRULE expansion with any RDATE and removing any EXDATE. A non-recurring event yields its single start if it falls in the window. Results are sorted and de-duplicated; pass limit > 0 to cap the count (0 means unlimited, subject to an internal safety bound).

func (*Event) Reply

func (e *Event) Reply(userEmail string, status PartStat) *Calendar

Reply builds a METHOD:REPLY calendar for this event on behalf of userEmail, from the parsed Event rather than the original bytes. Prefer GenerateRSVP when you still hold the invite's raw .ics; use Reply when you only have an Event in hand. status is the responder's PartStat (typically Accepted, Declined or Tentative).

type Frequency

type Frequency string

Frequency is an RRULE FREQ value (RFC 5545 §3.3.10).

const (
	Secondly Frequency = "SECONDLY"
	Minutely Frequency = "MINUTELY"
	Hourly   Frequency = "HOURLY"
	Daily    Frequency = "DAILY"
	Weekly   Frequency = "WEEKLY"
	Monthly  Frequency = "MONTHLY"
	Yearly   Frequency = "YEARLY"
)

type Method

type Method string

Method is an iTIP/iMIP method (RFC 5546) declared at the VCALENDAR level.

const (
	MethodRequest Method = "REQUEST"
	MethodReply   Method = "REPLY"
	MethodCancel  Method = "CANCEL"
	MethodPublish Method = "PUBLISH"
	MethodRefresh Method = "REFRESH"
	MethodCounter Method = "COUNTER"
)

type PartStat

type PartStat string

PartStat is an attendee participation status (PARTSTAT). The Accepted / Declined / Tentative values are also the legal responses to GenerateRSVP and Event.Reply.

const (
	PartStatNeedsAction PartStat = "NEEDS-ACTION"
	PartStatAccepted    PartStat = "ACCEPTED"
	PartStatDeclined    PartStat = "DECLINED"
	PartStatTentative   PartStat = "TENTATIVE"
	PartStatDelegated   PartStat = "DELEGATED"
)

type Period

type Period struct {
	Start time.Time
	End   time.Time
}

Period is a half-open time interval [Start, End).

func BusyPeriods

func BusyPeriods(events []*Event, from, to time.Time) []Period

BusyPeriods expands every event (including recurrences) into its occurrences within [from, to), clips each to the window, and merges overlapping or touching intervals into a sorted, non-overlapping list of busy time.

Events with zero duration, and all-day events, are honored: an all-day event contributes its full DTSTART–DTEND span. CANCELLED events are skipped.

func FreeBusy

func FreeBusy(events []*Event, from, to time.Time) (busy, free []Period)

FreeBusy returns the busy intervals within [from, to) (as BusyPeriods) and the free gaps between them that fill the rest of the window.

func (Period) Contains

func (p Period) Contains(t time.Time) bool

Contains reports whether t lies within [Start, End).

func (Period) Duration

func (p Period) Duration() time.Duration

Duration returns End-Start.

type RRule

type RRule struct {
	Freq       Frequency
	Interval   int
	Count      int
	Until      time.Time
	ByMonth    []time.Month
	ByMonthDay []int // day-of-month; negative counts from the end (-1 = last)
	ByDay      []WeekDay
	ByHour     []int
	ByMinute   []int
	BySetPos   []int // 1-based position within a period; negative from the end
	WeekStart  time.Weekday
}

RRule is a parsed RRULE recurrence (RFC 5545 §3.3.10). A zero Interval is treated as 1. Count and Until are mutually exclusive bounds; a zero value for each means "unbounded by that mechanism".

func ParseRRule

func ParseRRule(s string) (*RRule, error)

ParseRRule parses an RRULE value such as "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE;COUNT=10". A leading "RRULE:" prefix is tolerated. WeekStart defaults to Monday (RFC 5545's default).

func (*RRule) Between

func (r *RRule) Between(dtstart, from, to time.Time, limit int) []time.Time

Between enumerates occurrence start times of a rule anchored at dtstart, returning those in the half-open window [from, to). dtstart is always the first occurrence. If limit > 0, at most limit times are returned.

Expansion covers the common, real-world rules: FREQ DAILY/WEEKLY/MONTHLY/ YEARLY (and the sub-day frequencies), INTERVAL, COUNT/UNTIL, BYMONTH, BYMONTHDAY, BYDAY (including ordinals like 2MO / -1FR), and BYSETPOS. Unsupported parts (BYWEEKNO, BYYEARDAY) are ignored.

func (*RRule) String

func (r *RRule) String() string

String renders the rule back to RFC 5545 form with a stable field order.

type Status

type Status string

Status is a VEVENT status (RFC 5545 STATUS).

const (
	StatusConfirmed Status = "CONFIRMED"
	StatusTentative Status = "TENTATIVE"
	StatusCancelled Status = "CANCELLED"
)

type WeekDay

type WeekDay struct {
	Ord int
	Day time.Weekday
}

WeekDay is one BYDAY entry: a weekday with an optional ordinal. Ord is 0 for a plain weekday ("every Monday"), positive for the nth from the start of the period ("2MO" = second Monday), or negative for the nth from the end ("-1FR" = last Friday).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL