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 if params := p.ICalParameters; params != nil {
151 if tzids := params["TZID"]; len(tzids) > 0 {
152 tzid = tzids[0]
153 }
154 }
155
156 // Try parsing with timezone
157 var t time.Time
158 var err error
159
160 // RFC 5545 formats
161 formats := []string{
162 "20060102T150405Z", // UTC
163 "20060102T150405", // Local/TZID
164 "20060102", // Date only (all-day)
165 time.RFC3339, // Fallback
166 }
167
168 for _, format := range formats {
169 t, err = time.Parse(format, value)
170 if err == nil {
171 break
172 }
173 }
174
175 if err != nil {
176 return time.Time{}, fmt.Errorf("parse timestamp: %w", err)
177 }
178
179 // Apply timezone if specified
180 if tzid != "" && !strings.HasSuffix(value, "Z") {
181 if loc, locErr := time.LoadLocation(tzid); locErr == nil {
182 t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), loc)
183 }
184 }
185
186 return t, nil
187}
188
189// extractEmail strips "mailto:" prefix and CN parameter from organizer/attendee fields
190func extractEmail(mailto string) string {
191 // Strip mailto: prefix
192 email := strings.TrimPrefix(mailto, "mailto:")
193 email = strings.TrimPrefix(email, "MAILTO:")
194
195 // Strip CN and other parameters (format: CN=Name:email@example.com)
196 if idx := strings.Index(email, ":"); idx != -1 {
197 email = email[idx+1:]
198 }
199
200 return strings.TrimSpace(email)
201}