// Package icalendar produces RFC 5545 calendar objects. // // WHY THIS RATHER THAN A CALENDAR INTEGRATION. Integrating with Google Calendar and Microsoft Graph means two // vendor specific OAuth applications, two consent screens, two credential sets to hold, and a permanent // dependency on two APIs. That cuts directly against the both clouds no lock in mandate, and it does not // help a candidate whose calendar is neither. An iCalendar REQUEST attachment is the interoperable standard // that every one of those calendars already accepts, needs no credentials at all, and is a few hundred lines // of text generation with no dependency. // // EVERY VALUE IS ESCAPED AND EVERY LINE IS FOLDED. Both matter more than they look. An unescaped comma or // semicolon in a summary silently truncates the field in the recipient's calendar; an unfolded line over 75 // octets is a protocol violation that some clients reject outright and others silently mangle. A calendar // invite that arrives corrupted is worse than none, because the recruiter believes it was sent. package icalendar import ( "fmt" "strings" "time" ) // Event is one meeting. type Event struct { // UID must be globally unique and STABLE across updates to the same meeting, because that is how a // calendar client knows an update is an update rather than a second meeting. UID string Summary string Description string Location string Start time.Time End time.Time // Organizer is who the invite comes from, and Attendee is who it goes to. Both are bare addresses. OrganizerName string OrganizerEmail string AttendeeName string AttendeeEmail string // Sequence increments on each revision of the same UID. A client ignores an update whose sequence is not // higher than the one it already has, so a reschedule that forgets to increment simply does not appear. Sequence int // Cancelled produces a CANCEL rather than a REQUEST, which is how a calendar removes the entry instead // of leaving a meeting nobody is attending. Cancelled bool } // Render produces the calendar object. The output is CRLF terminated, as the specification requires: LF only // is the single most common reason an otherwise correct invite is rejected. func Render(e Event) string { method := "REQUEST" status := "CONFIRMED" if e.Cancelled { method, status = "CANCEL", "CANCELLED" } var b strings.Builder w := func(s string) { b.WriteString(fold(s)); b.WriteString("\r\n") } w("BEGIN:VCALENDAR") w("VERSION:2.0") w("PRODID:-//missionMATCH//Interview Scheduling//EN") w("CALSCALE:GREGORIAN") w("METHOD:" + method) w("BEGIN:VEVENT") w("UID:" + escape(e.UID)) w("SEQUENCE:" + fmt.Sprint(e.Sequence)) // DTSTAMP is when this OBJECT was produced, distinct from when the meeting starts. Clients use it to // order revisions that share a sequence number. w("DTSTAMP:" + stamp(time.Now())) w("DTSTART:" + stamp(e.Start)) w("DTEND:" + stamp(e.End)) w("SUMMARY:" + escape(e.Summary)) if e.Description != "" { w("DESCRIPTION:" + escape(e.Description)) } if e.Location != "" { w("LOCATION:" + escape(e.Location)) } if e.OrganizerEmail != "" { w(fmt.Sprintf("ORGANIZER;CN=%s:mailto:%s", escapeParam(e.OrganizerName), e.OrganizerEmail)) } if e.AttendeeEmail != "" { // PARTSTAT NEEDS-ACTION and RSVP TRUE are what make a calendar client show the accept and decline // buttons. Without them the entry appears with no way to respond, and the organiser never learns // whether the candidate is coming. w(fmt.Sprintf( "ATTENDEE;CN=%s;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:%s", escapeParam(e.AttendeeName), e.AttendeeEmail)) } w("STATUS:" + status) if !e.Cancelled { // A reminder fifteen minutes before. Calendars that support it show it; those that do not ignore the // block, so it costs nothing to include. w("BEGIN:VALARM") w("TRIGGER:-PT15M") w("ACTION:DISPLAY") w("DESCRIPTION:" + escape(e.Summary)) w("END:VALARM") } w("END:VEVENT") w("END:VCALENDAR") return b.String() } // stamp formats a time as UTC in the basic format the specification requires. ALWAYS UTC, with the trailing // Z, so no recipient has to agree with us about a time zone database. A meeting rendered in local time // without a VTIMEZONE block is the other classic way an invite lands an hour out. func stamp(t time.Time) string { return t.UTC().Format("20060102T150405Z") } // escape handles the four characters RFC 5545 reserves in a text value. An unescaped comma or semicolon // silently truncates the field in the recipient's calendar, which is how a location becomes "Building 4" // instead of "Building 4, third floor, ask for Dana". func escape(s string) string { r := strings.NewReplacer( "\\", "\\\\", ";", "\\;", ",", "\\,", "\r\n", "\\n", "\n", "\\n", "\r", "\\n", ) return r.Replace(s) } // escapeParam handles a parameter value, where the rules differ: a quote cannot be escaped at all, so it is // removed, and a value containing a comma, semicolon or colon must be quoted. func escapeParam(s string) string { s = strings.Map(func(ch rune) rune { if ch == '"' || ch < 0x20 { return -1 } return ch }, s) if strings.ContainsAny(s, ",;:") { return `"` + s + `"` } return s } // fold wraps a content line at 75 octets, continuing with a single leading space, as the specification // requires. It counts BYTES rather than runes and never splits a multi byte character, because a fold in the // middle of a UTF-8 sequence produces a line no client can decode. func fold(line string) string { const limit = 75 if len(line) <= limit { return line } var b strings.Builder b.Grow(len(line) + len(line)/limit*3) i := 0 for i < len(line) { end := i + limit if i > 0 { // A continuation line carries a leading space, which counts toward the limit. end = i + limit - 1 } if end >= len(line) { end = len(line) } else { // Walk back off a continuation byte so a multi byte character is never split. for end > i && line[end]&0xC0 == 0x80 { end-- } } if i > 0 { b.WriteString("\r\n ") } b.WriteString(line[i:end]) i = end } return b.String() }