calendar.go

  1package calendar
  2
  3import (
  4	"bytes"
  5	"fmt"
  6	"strings"
  7	"time"
  8
  9	ics "github.com/arran4/golang-ical"
 10)
 11
 12// Event represents a parsed calendar event from an .ics attachment
 13type Event struct {
 14	UID         string
 15	Summary     string // Event title
 16	Description string
 17	Location    string
 18	Start       time.Time
 19	End         time.Time
 20	Organizer   string // Organizer email
 21	Status      string // CONFIRMED, TENTATIVE, CANCELLED
 22	Method      string // REQUEST, REPLY, CANCEL
 23}
 24
 25// ParseICS extracts the first VEVENT from .ics data
 26func ParseICS(data []byte) (*Event, error) {
 27	cal, err := ics.ParseCalendar(bytes.NewReader(data))
 28	if err != nil {
 29		return nil, fmt.Errorf("parse calendar: %w", err)
 30	}
 31
 32	events := cal.Events()
 33	if len(events) == 0 {
 34		return nil, fmt.Errorf("no VEVENT found")
 35	}
 36
 37	vevent := events[0]
 38
 39	// Extract properties
 40	uid := getEventProperty(vevent, ics.ComponentPropertyUniqueId)
 41	summary := getEventProperty(vevent, ics.ComponentPropertySummary)
 42	description := getEventProperty(vevent, ics.ComponentPropertyDescription)
 43	location := getEventProperty(vevent, ics.ComponentPropertyLocation)
 44	organizer := extractEmail(getEventProperty(vevent, ics.ComponentPropertyOrganizer))
 45	status := getEventProperty(vevent, ics.ComponentPropertyStatus)
 46
 47	// Get METHOD from calendar level
 48	method := ""
 49	for _, prop := range cal.CalendarProperties {
 50		if prop.IANAToken == string(ics.PropertyMethod) {
 51			method = prop.Value
 52			break
 53		}
 54	}
 55
 56	// Parse timestamps
 57	start, _ := parseEventTimestamp(vevent, ics.ComponentPropertyDtStart)
 58	end, _ := parseEventTimestamp(vevent, ics.ComponentPropertyDtEnd)
 59
 60	return &Event{
 61		UID:         uid,
 62		Summary:     summary,
 63		Description: description,
 64		Location:    location,
 65		Start:       start,
 66		End:         end,
 67		Organizer:   organizer,
 68		Status:      status,
 69		Method:      method,
 70	}, nil
 71}
 72
 73// GenerateRSVP creates a RFC 6047 (iMIP) compliant reply .ics.
 74// Google Calendar requires:
 75// - METHOD:REPLY at calendar level
 76// - Only the responding attendee in VEVENT (others removed)
 77// - Updated PARTSTAT on the attendee
 78// - Current DTSTAMP
 79func GenerateRSVP(originalData []byte, userEmail, response string) ([]byte, error) {
 80	// response: "ACCEPTED", "DECLINED", "TENTATIVE"
 81
 82	cal, err := ics.ParseCalendar(bytes.NewReader(originalData))
 83	if err != nil {
 84		return nil, fmt.Errorf("parse calendar: %w", err)
 85	}
 86
 87	// Set METHOD:REPLY
 88	cal.SetMethod(ics.MethodReply)
 89
 90	userEmail = strings.ToLower(strings.TrimSpace(userEmail))
 91
 92	for _, vevent := range cal.Events() {
 93		// Update DTSTAMP to current time
 94		vevent.SetDtStampTime(time.Now().UTC())
 95
 96		// Find the responding attendee and remove all others
 97		var matchedAttendee *ics.Attendee
 98		attendees := vevent.Attendees()
 99		for _, attendee := range attendees {
100			attendeeEmail := strings.ToLower(extractEmail(attendee.Email()))
101			if strings.Contains(attendeeEmail, userEmail) || strings.Contains(userEmail, attendeeEmail) {
102				matchedAttendee = attendee
103				break
104			}
105		}
106
107		// Remove all ATTENDEE properties
108		vevent.RemoveProperty(ics.ComponentPropertyAttendee)
109
110		// Re-add only the responding attendee with updated PARTSTAT and RSVP=TRUE
111		if matchedAttendee != nil {
112			matchedAttendee.ICalParameters[string(ics.ParameterParticipationStatus)] = []string{response}
113			matchedAttendee.ICalParameters["RSVP"] = []string{"TRUE"}
114			vevent.Properties = append(vevent.Properties, matchedAttendee.IANAProperty)
115		} else {
116			// Attendee not found in original - add ourselves with full parameters
117			vevent.AddAttendee("mailto:"+userEmail,
118				ics.WithRSVP(true),
119				ics.ParticipationStatus(ics.ParticipationStatusNeedsAction),
120				ics.CalendarUserTypeIndividual,
121				ics.ParticipationRoleReqParticipant,
122			)
123			for _, att := range vevent.Attendees() {
124				att.ICalParameters[string(ics.ParameterParticipationStatus)] = []string{response}
125			}
126		}
127	}
128
129	return []byte(cal.Serialize()), nil
130}
131
132// getEventProperty extracts a property value from a VEVENT
133func getEventProperty(vevent *ics.VEvent, prop ics.ComponentProperty) string {
134	p := vevent.GetProperty(prop)
135	if p == nil {
136		return ""
137	}
138	return p.Value
139}
140
141// parseEventTimestamp parses DTSTART or DTEND with timezone handling
142func parseEventTimestamp(vevent *ics.VEvent, prop ics.ComponentProperty) (time.Time, error) {
143	p := vevent.GetProperty(prop)
144	if p == nil {
145		return time.Time{}, fmt.Errorf("property not found")
146	}
147
148	value := p.Value
149	var tzid string
150	var isDateOnly bool
151	if params := p.ICalParameters; params != nil {
152		if tzids := params["TZID"]; len(tzids) > 0 {
153			tzid = tzids[0]
154		}
155		if vals := params["VALUE"]; len(vals) > 0 && strings.EqualFold(vals[0], "DATE") {
156			isDateOnly = true
157		}
158	}
159	// RFC 5545 DATE form is YYYYMMDD (8 chars, no time component).
160	if !isDateOnly && len(value) == 8 {
161		isDateOnly = true
162	}
163
164	// Try parsing with timezone
165	var t time.Time
166	var err error
167
168	// RFC 5545 formats
169	formats := []string{
170		"20060102T150405Z", // UTC
171		"20060102T150405",  // Local/TZID
172		"20060102",         // Date only (all-day)
173		time.RFC3339,       // Fallback
174	}
175
176	for _, format := range formats {
177		t, err = time.Parse(format, value)
178		if err == nil {
179			break
180		}
181	}
182
183	if err != nil {
184		return time.Time{}, fmt.Errorf("parse timestamp: %w", err)
185	}
186
187	// Apply timezone if specified. RFC 5545: VALUE=DATE has no timezone, so
188	// TZID must be ignored for date-only values even when present.
189	if tzid != "" && !strings.HasSuffix(value, "Z") && !isDateOnly {
190		if loc, locErr := time.LoadLocation(tzid); locErr == nil {
191			t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), loc)
192		}
193	}
194
195	return t, nil
196}
197
198// extractEmail strips "mailto:" prefix and CN parameter from organizer/attendee fields
199func extractEmail(mailto string) string {
200	// Strip mailto: prefix
201	email := strings.TrimPrefix(mailto, "mailto:")
202	email = strings.TrimPrefix(email, "MAILTO:")
203
204	// Strip CN and other parameters (format: CN=Name:email@example.com)
205	if idx := strings.Index(email, ":"); idx != -1 {
206		email = email[idx+1:]
207	}
208
209	return strings.TrimSpace(email)
210}