diff --git a/calendar/calendar.go b/calendar/calendar.go deleted file mode 100644 index 16feb0cf..00000000 --- a/calendar/calendar.go +++ /dev/null @@ -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) -} diff --git a/calendar/calendar_test.go b/calendar/calendar_test.go deleted file mode 100644 index 71554dfa..00000000 --- a/calendar/calendar_test.go +++ /dev/null @@ -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) - } - }) - } -} diff --git a/go.mod b/go.mod index e389c1b6..b6d37350 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,6 @@ require ( git.sr.ht/~rockorager/go-jmap v0.5.3 github.com/ProtonMail/go-crypto v1.4.1 github.com/PuerkitoBio/goquery v1.12.0 - github.com/arran4/golang-ical v0.3.5 github.com/charmbracelet/x/ansi v0.11.7 github.com/emersion/go-imap/v2 v2.0.0-beta.8 github.com/emersion/go-maildir v0.6.0 @@ -17,6 +16,7 @@ require ( github.com/emersion/go-pgpmail v0.2.2 github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 github.com/floatpane/bubble-overlay v0.0.1 + github.com/floatpane/go-icalendar v0.0.1 github.com/floatpane/go-openpgp-card-hl v0.0.1 github.com/floatpane/go-secretbox v0.1.0 github.com/floatpane/go-uds-jsonrpc v0.0.1 @@ -39,6 +39,7 @@ require ( cunicu.li/go-iso7816 v0.8.8 // indirect cunicu.li/go-openpgp-card v0.3.11 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect + github.com/arran4/golang-ical v0.3.5 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect diff --git a/go.sum b/go.sum index a2250c41..b533e757 100644 --- a/go.sum +++ b/go.sum @@ -70,6 +70,8 @@ github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTe github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= github.com/floatpane/bubble-overlay v0.0.1 h1:5xU8cNigDPYegvgGMfOG23fIDXhrqXPvLTaEB7uHGK4= github.com/floatpane/bubble-overlay v0.0.1/go.mod h1:Csi1byxb9L8EAb8X13XdWF5aX5YiBD5C9WEWACyGa8A= +github.com/floatpane/go-icalendar v0.0.1 h1:lF9NhEI4TobX8valDakAFfCnBhM2GDITWMVymhXzD8c= +github.com/floatpane/go-icalendar v0.0.1/go.mod h1:LSy9G+LwUZtfNIAjLlEVRXkuc2A+hq6+pVCIFOiEAyE= github.com/floatpane/go-openpgp-card-hl v0.0.1 h1:1DYmzwGDb8eneZxbc/xtwjXeFY8DFL3eYnUooMT0L0w= github.com/floatpane/go-openpgp-card-hl v0.0.1/go.mod h1:Mrx+ukCnpEpMAxyB0p8Ch2gu78Q3Ir40BxBybb2jirw= github.com/floatpane/go-secretbox v0.1.0 h1:xNryazmCP0oR/yVxIkHRc5bcV56YrbisY+bMl8BBfwU= diff --git a/main.go b/main.go index b9494921..406397a5 100644 --- a/main.go +++ b/main.go @@ -28,12 +28,12 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" + calendar "github.com/floatpane/go-icalendar" "github.com/floatpane/matcha/backend" _ "github.com/floatpane/matcha/backend/imap" _ "github.com/floatpane/matcha/backend/jmap" _ "github.com/floatpane/matcha/backend/maildir" _ "github.com/floatpane/matcha/backend/pop3" - "github.com/floatpane/matcha/calendar" matchaCli "github.com/floatpane/matcha/cli" "github.com/floatpane/matcha/clib" "github.com/floatpane/matcha/clib/macos" diff --git a/testdata/invites/simple.ics b/testdata/invites/simple.ics deleted file mode 100644 index c96f4d50..00000000 --- a/testdata/invites/simple.ics +++ /dev/null @@ -1,18 +0,0 @@ -BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//Test//Test//EN -METHOD:REQUEST -BEGIN:VEVENT -UID:test-event-123@example.com -DTSTAMP:20260415T120000Z -DTSTART:20260421T140000Z -DTEND:20260421T153000Z -SUMMARY:Q2 Planning Meeting -DESCRIPTION:Discuss roadmap priorities and resource allocation for Q2. -LOCATION:Conference Room A -ORGANIZER:mailto:alice@company.com -ATTENDEE;CN=Bob Smith;PARTSTAT=NEEDS-ACTION:mailto:bob@company.com -ATTENDEE;CN=Carol Jones;PARTSTAT=NEEDS-ACTION:mailto:carol@company.com -STATUS:CONFIRMED -END:VEVENT -END:VCALENDAR diff --git a/tui/email_view.go b/tui/email_view.go index bea7d001..e764d6aa 100644 --- a/tui/email_view.go +++ b/tui/email_view.go @@ -10,7 +10,7 @@ import ( "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" - "github.com/floatpane/matcha/calendar" + calendar "github.com/floatpane/go-icalendar" "github.com/floatpane/matcha/config" "github.com/floatpane/matcha/fetcher" "github.com/floatpane/matcha/theme" diff --git a/tui/messages.go b/tui/messages.go index ff048e4a..cd253290 100644 --- a/tui/messages.go +++ b/tui/messages.go @@ -1,8 +1,8 @@ package tui import ( + calendar "github.com/floatpane/go-icalendar" "github.com/floatpane/matcha/backend" - "github.com/floatpane/matcha/calendar" "github.com/floatpane/matcha/config" "github.com/floatpane/matcha/daemonrpc" "github.com/floatpane/matcha/fetcher"