@@ -1,210 +0,0 @@
-package calendar
-
-import (
- "bytes"
- "fmt"
- "strings"
- "time"
-
- ics "github.com/arran4/golang-ical"
-)
-
-// Event represents a parsed calendar event from an .ics attachment
-type Event struct {
- UID string
- Summary string // Event title
- Description string
- Location string
- Start time.Time
- End time.Time
- Organizer string // Organizer email
- Status string // CONFIRMED, TENTATIVE, CANCELLED
- Method string // REQUEST, REPLY, CANCEL
-}
-
-// ParseICS extracts the first VEVENT from .ics data
-func ParseICS(data []byte) (*Event, error) {
- cal, err := ics.ParseCalendar(bytes.NewReader(data))
- if err != nil {
- return nil, fmt.Errorf("parse calendar: %w", err)
- }
-
- events := cal.Events()
- if len(events) == 0 {
- return nil, fmt.Errorf("no VEVENT found")
- }
-
- vevent := events[0]
-
- // Extract properties
- uid := getEventProperty(vevent, ics.ComponentPropertyUniqueId)
- summary := getEventProperty(vevent, ics.ComponentPropertySummary)
- description := getEventProperty(vevent, ics.ComponentPropertyDescription)
- location := getEventProperty(vevent, ics.ComponentPropertyLocation)
- organizer := extractEmail(getEventProperty(vevent, ics.ComponentPropertyOrganizer))
- status := getEventProperty(vevent, ics.ComponentPropertyStatus)
-
- // Get METHOD from calendar level
- method := ""
- for _, prop := range cal.CalendarProperties {
- if prop.IANAToken == string(ics.PropertyMethod) {
- method = prop.Value
- break
- }
- }
-
- // Parse timestamps
- start, _ := parseEventTimestamp(vevent, ics.ComponentPropertyDtStart)
- end, _ := parseEventTimestamp(vevent, ics.ComponentPropertyDtEnd)
-
- return &Event{
- UID: uid,
- Summary: summary,
- Description: description,
- Location: location,
- Start: start,
- End: end,
- Organizer: organizer,
- Status: status,
- Method: method,
- }, nil
-}
-
-// GenerateRSVP creates a RFC 6047 (iMIP) compliant reply .ics.
-// Google Calendar requires:
-// - METHOD:REPLY at calendar level
-// - Only the responding attendee in VEVENT (others removed)
-// - Updated PARTSTAT on the attendee
-// - Current DTSTAMP
-func GenerateRSVP(originalData []byte, userEmail, response string) ([]byte, error) {
- // response: "ACCEPTED", "DECLINED", "TENTATIVE"
-
- cal, err := ics.ParseCalendar(bytes.NewReader(originalData))
- if err != nil {
- return nil, fmt.Errorf("parse calendar: %w", err)
- }
-
- // Set METHOD:REPLY
- cal.SetMethod(ics.MethodReply)
-
- userEmail = strings.ToLower(strings.TrimSpace(userEmail))
-
- for _, vevent := range cal.Events() {
- // Update DTSTAMP to current time
- vevent.SetDtStampTime(time.Now().UTC())
-
- // Find the responding attendee and remove all others
- var matchedAttendee *ics.Attendee
- attendees := vevent.Attendees()
- for _, attendee := range attendees {
- attendeeEmail := strings.ToLower(extractEmail(attendee.Email()))
- if strings.Contains(attendeeEmail, userEmail) || strings.Contains(userEmail, attendeeEmail) {
- matchedAttendee = attendee
- break
- }
- }
-
- // Remove all ATTENDEE properties
- vevent.RemoveProperty(ics.ComponentPropertyAttendee)
-
- // Re-add only the responding attendee with updated PARTSTAT and RSVP=TRUE
- if matchedAttendee != nil {
- matchedAttendee.ICalParameters[string(ics.ParameterParticipationStatus)] = []string{response}
- matchedAttendee.ICalParameters["RSVP"] = []string{"TRUE"}
- vevent.Properties = append(vevent.Properties, matchedAttendee.IANAProperty)
- } else {
- // Attendee not found in original - add ourselves with full parameters
- vevent.AddAttendee("mailto:"+userEmail,
- ics.WithRSVP(true),
- ics.ParticipationStatusNeedsAction,
- ics.CalendarUserTypeIndividual,
- ics.ParticipationRoleReqParticipant,
- )
- for _, att := range vevent.Attendees() {
- att.ICalParameters[string(ics.ParameterParticipationStatus)] = []string{response}
- }
- }
- }
-
- return []byte(cal.Serialize()), nil
-}
-
-// getEventProperty extracts a property value from a VEVENT
-func getEventProperty(vevent *ics.VEvent, prop ics.ComponentProperty) string {
- p := vevent.GetProperty(prop)
- if p == nil {
- return ""
- }
- return p.Value
-}
-
-// parseEventTimestamp parses DTSTART or DTEND with timezone handling
-func parseEventTimestamp(vevent *ics.VEvent, prop ics.ComponentProperty) (time.Time, error) {
- p := vevent.GetProperty(prop)
- if p == nil {
- return time.Time{}, fmt.Errorf("property not found")
- }
-
- value := p.Value
- var tzid string
- var isDateOnly bool
- if params := p.ICalParameters; params != nil {
- if tzids := params["TZID"]; len(tzids) > 0 {
- tzid = tzids[0]
- }
- if vals := params["VALUE"]; len(vals) > 0 && strings.EqualFold(vals[0], "DATE") {
- isDateOnly = true
- }
- }
- // RFC 5545 DATE form is YYYYMMDD (8 chars, no time component).
- if !isDateOnly && len(value) == 8 {
- isDateOnly = true
- }
-
- // Try parsing with timezone
- var t time.Time
- var err error
-
- // RFC 5545 formats
- formats := []string{
- "20060102T150405Z", // UTC
- "20060102T150405", // Local/TZID
- "20060102", // Date only (all-day)
- time.RFC3339, // Fallback
- }
-
- for _, format := range formats {
- t, err = time.Parse(format, value)
- if err == nil {
- break
- }
- }
-
- if err != nil {
- return time.Time{}, fmt.Errorf("parse timestamp: %w", err)
- }
-
- // Apply timezone if specified. RFC 5545: VALUE=DATE has no timezone, so
- // TZID must be ignored for date-only values even when present.
- if tzid != "" && !strings.HasSuffix(value, "Z") && !isDateOnly {
- if loc, locErr := time.LoadLocation(tzid); locErr == nil {
- t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), loc)
- }
- }
-
- return t, nil
-}
-
-// extractEmail strips "mailto:" prefix and CN parameter from organizer/attendee fields
-func extractEmail(mailto string) string {
- // Strip mailto: prefix
- email := strings.TrimPrefix(mailto, "mailto:")
- email = strings.TrimPrefix(email, "MAILTO:")
-
- // Strip CN and other parameters (format: CN=Name:email@example.com)
- if idx := strings.Index(email, ":"); idx != -1 {
- email = email[idx+1:]
- }
-
- return strings.TrimSpace(email)
-}
@@ -1,237 +0,0 @@
-package calendar
-
-import (
- "os"
- "strings"
- "testing"
- "time"
-)
-
-func TestParseICS_Simple(t *testing.T) {
- data, err := os.ReadFile("../testdata/invites/simple.ics")
- if err != nil {
- t.Fatalf("Failed to read test fixture: %v", err)
- }
-
- event, err := ParseICS(data)
- if err != nil {
- t.Fatalf("ParseICS failed: %v", err)
- }
-
- if event.UID != "test-event-123@example.com" {
- t.Errorf("Expected UID test-event-123@example.com, got %s", event.UID)
- }
-
- if event.Summary != "Q2 Planning Meeting" {
- t.Errorf("Expected summary 'Q2 Planning Meeting', got %s", event.Summary)
- }
-
- if event.Location != "Conference Room A" {
- t.Errorf("Expected location 'Conference Room A', got %s", event.Location)
- }
-
- if event.Organizer != "alice@company.com" {
- t.Errorf("Expected organizer alice@company.com, got %s", event.Organizer)
- }
-
- if event.Status != "CONFIRMED" {
- t.Errorf("Expected status CONFIRMED, got %s", event.Status)
- }
-
- if event.Method != "REQUEST" {
- t.Errorf("Expected method REQUEST, got %s", event.Method)
- }
-
- expectedStart := time.Date(2026, 4, 21, 14, 0, 0, 0, time.UTC)
- if !event.Start.Equal(expectedStart) {
- t.Errorf("Expected start %v, got %v", expectedStart, event.Start)
- }
-
- expectedEnd := time.Date(2026, 4, 21, 15, 30, 0, 0, time.UTC)
- if !event.End.Equal(expectedEnd) {
- t.Errorf("Expected end %v, got %v", expectedEnd, event.End)
- }
-}
-
-func TestParseICS_NoEvent(t *testing.T) {
- data := []byte(`BEGIN:VCALENDAR
-VERSION:2.0
-PRODID:-//Test//Test//EN
-END:VCALENDAR`)
-
- _, err := ParseICS(data)
- if err == nil {
- t.Error("Expected error for calendar with no VEVENT")
- }
- if !strings.Contains(err.Error(), "no VEVENT") {
- t.Errorf("Expected 'no VEVENT' error, got: %v", err)
- }
-}
-
-func TestParseICS_Malformed(t *testing.T) {
- data := []byte(`INVALID ICAL DATA`)
-
- _, err := ParseICS(data)
- if err == nil {
- t.Error("Expected error for malformed iCalendar data")
- }
-}
-
-func TestGenerateRSVP(t *testing.T) {
- data, err := os.ReadFile("../testdata/invites/simple.ics")
- if err != nil {
- t.Fatalf("Failed to read test fixture: %v", err)
- }
-
- responses := []string{"ACCEPTED", "DECLINED", "TENTATIVE"}
-
- for _, response := range responses {
- t.Run(response, func(t *testing.T) {
- rsvpData, err := GenerateRSVP(data, "bob@company.com", response)
- if err != nil {
- t.Fatalf("GenerateRSVP failed for %s: %v", response, err)
- }
-
- rsvpStr := string(rsvpData)
-
- // Check METHOD:REPLY is set
- if !strings.Contains(rsvpStr, "METHOD:REPLY") {
- t.Error("Expected METHOD:REPLY in RSVP")
- }
-
- // Check PARTSTAT is updated
- if !strings.Contains(rsvpStr, "PARTSTAT="+response) {
- t.Errorf("Expected PARTSTAT=%s in RSVP", response)
- }
-
- // RFC 6047: only the responding attendee should remain
- attendeeCount := strings.Count(rsvpStr, "ATTENDEE")
- if attendeeCount != 1 {
- t.Errorf("Expected exactly 1 ATTENDEE in RSVP, got %d", attendeeCount)
- }
-
- // Should contain responding user's email
- if !strings.Contains(rsvpStr, "bob@company.com") {
- t.Error("Expected bob@company.com in RSVP attendee")
- }
-
- // Should NOT contain other attendees
- if strings.Contains(rsvpStr, "carol@company.com") {
- t.Error("RSVP should not contain other attendees")
- }
-
- // Verify it's still valid iCalendar
- _, err = ParseICS(rsvpData)
- if err != nil {
- t.Errorf("Generated RSVP is not valid iCalendar: %v", err)
- }
- })
- }
-}
-
-// buildICS wraps DTSTART/DTEND lines into a minimal VCALENDAR for ParseICS.
-func buildICS(dtstart, dtend string) []byte {
- return []byte("BEGIN:VCALENDAR\r\n" +
- "VERSION:2.0\r\n" +
- "PRODID:-//Test//Test//EN\r\n" +
- "BEGIN:VEVENT\r\n" +
- "UID:date-only@example.com\r\n" +
- "DTSTAMP:20260415T120000Z\r\n" +
- dtstart + "\r\n" +
- dtend + "\r\n" +
- "SUMMARY:Test\r\n" +
- "END:VEVENT\r\n" +
- "END:VCALENDAR\r\n")
-}
-
-func TestParseICS_DateOnly(t *testing.T) {
- wantStart := time.Date(2026, 4, 21, 0, 0, 0, 0, time.UTC)
- wantEnd := time.Date(2026, 4, 22, 0, 0, 0, 0, time.UTC)
-
- tests := []struct {
- name string
- dtstart string
- dtend string
- }{
- {
- name: "VALUE=DATE without TZID",
- dtstart: "DTSTART;VALUE=DATE:20260421",
- dtend: "DTEND;VALUE=DATE:20260422",
- },
- {
- // Regression: TZID present on a date-only value must be ignored
- // (RFC 5545 forbids TZID with VALUE=DATE; some producers emit it anyway).
- name: "VALUE=DATE with TZID is ignored",
- dtstart: "DTSTART;TZID=America/New_York;VALUE=DATE:20260421",
- dtend: "DTEND;TZID=America/New_York;VALUE=DATE:20260422",
- },
- {
- // Shape-only detection: no VALUE param, but YYYYMMDD value with TZID.
- name: "YYYYMMDD shape with TZID is treated as date-only",
- dtstart: "DTSTART;TZID=America/Los_Angeles:20260421",
- dtend: "DTEND;TZID=America/Los_Angeles:20260422",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- event, err := ParseICS(buildICS(tt.dtstart, tt.dtend))
- if err != nil {
- t.Fatalf("ParseICS failed: %v", err)
- }
- if !event.Start.Equal(wantStart) {
- t.Errorf("Start = %v, want %v", event.Start.UTC(), wantStart)
- }
- if !event.End.Equal(wantEnd) {
- t.Errorf("End = %v, want %v", event.End.UTC(), wantEnd)
- }
- })
- }
-}
-
-func TestParseICS_TimedWithTZID(t *testing.T) {
- // Existing behavior: timed values with TZID keep their zone semantics.
- event, err := ParseICS(buildICS(
- "DTSTART;TZID=America/New_York:20260421T140000",
- "DTEND;TZID=America/New_York:20260421T153000",
- ))
- if err != nil {
- t.Fatalf("ParseICS failed: %v", err)
- }
-
- loc, err := time.LoadLocation("America/New_York")
- if err != nil {
- t.Skipf("America/New_York unavailable on this system: %v", err)
- }
- wantStart := time.Date(2026, 4, 21, 14, 0, 0, 0, loc)
- wantEnd := time.Date(2026, 4, 21, 15, 30, 0, 0, loc)
-
- if !event.Start.Equal(wantStart) {
- t.Errorf("Start = %v, want %v", event.Start, wantStart)
- }
- if !event.End.Equal(wantEnd) {
- t.Errorf("End = %v, want %v", event.End, wantEnd)
- }
-}
-
-func TestExtractEmail(t *testing.T) {
- tests := []struct {
- input string
- expected string
- }{
- {"mailto:user@example.com", "user@example.com"},
- {"MAILTO:user@example.com", "user@example.com"},
- {"CN=John Doe:user@example.com", "user@example.com"},
- {"user@example.com", "user@example.com"},
- {" user@example.com ", "user@example.com"},
- }
-
- for _, tt := range tests {
- t.Run(tt.input, func(t *testing.T) {
- result := extractEmail(tt.input)
- if result != tt.expected {
- t.Errorf("extractEmail(%q) = %q, want %q", tt.input, result, tt.expected)
- }
- })
- }
-}