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}