From 4bbef7e97b15878ffde96b546ac9afa0ff4ca3f5 Mon Sep 17 00:00:00 2001 From: Drew Smirnoff Date: Sat, 18 Apr 2026 21:50:59 +0400 Subject: [PATCH] feat: calendar events support (#678) --- calendar/calendar.go | 201 +++++++++++++++++++++++++++++++++ calendar/calendar_test.go | 152 +++++++++++++++++++++++++ config/cache.go | 2 + docs/docs/Features/CALENDAR.md | 71 ++++++++++++ fetcher/fetcher.go | 38 +++++-- go.mod | 1 + go.sum | 2 + main.go | 100 +++++++++++++++- sender/sender.go | 187 ++++++++++++++++++++++++++++++ testdata/invites/simple.ics | 18 +++ tui/email_view.go | 141 ++++++++++++++++++++--- tui/messages.go | 18 +++ 12 files changed, 902 insertions(+), 29 deletions(-) create mode 100644 calendar/calendar.go create mode 100644 calendar/calendar_test.go create mode 100644 docs/docs/Features/CALENDAR.md create mode 100644 testdata/invites/simple.ics diff --git a/calendar/calendar.go b/calendar/calendar.go new file mode 100644 index 0000000000000000000000000000000000000000..c3d27c0fc527657f704c8adf2f8b032754f6d442 --- /dev/null +++ b/calendar/calendar.go @@ -0,0 +1,201 @@ +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.ParticipationStatus(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 + if params := p.ICalParameters; params != nil { + if tzids := params["TZID"]; len(tzids) > 0 { + tzid = tzids[0] + } + } + + // 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 + if tzid != "" && !strings.HasSuffix(value, "Z") { + 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 new file mode 100644 index 0000000000000000000000000000000000000000..4d7da3efce072f68c9e1bf9f3f6e0209b28fc8aa --- /dev/null +++ b/calendar/calendar_test.go @@ -0,0 +1,152 @@ +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) + } + }) + } +} + +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/config/cache.go b/config/cache.go index 137adec8a8b12b82c448a45924f79df5cb95041d..90feffe4988c7c67c53cce0a8f2cf003babcb437 100644 --- a/config/cache.go +++ b/config/cache.go @@ -415,6 +415,8 @@ type CachedAttachment struct { IsSMIMESignature bool `json:"is_smime_signature,omitempty"` SMIMEVerified bool `json:"smime_verified,omitempty"` IsSMIMEEncrypted bool `json:"is_smime_encrypted,omitempty"` + IsCalendarInvite bool `json:"is_calendar_invite,omitempty"` + CalendarData []byte `json:"calendar_data,omitempty"` // Raw .ics data for calendar invites } // CachedEmailBody stores the body and attachment metadata for a single email. diff --git a/docs/docs/Features/CALENDAR.md b/docs/docs/Features/CALENDAR.md new file mode 100644 index 0000000000000000000000000000000000000000..d6f10465cea847e0f4fdda2203cf246cd9121958 --- /dev/null +++ b/docs/docs/Features/CALENDAR.md @@ -0,0 +1,71 @@ +# Calendar Invites + +Matcha can parse and display calendar invites (`.ics` attachments) directly in the email view, and lets you RSVP without leaving the terminal. + +## Features + +- **📅 Invite Detection**: Automatically detects `text/calendar` MIME parts and `.ics` attachments. +- **📋 Event Details Card**: Displays a styled card with the event title, date/time, location, and organizer. +- **✅ RSVP Support**: Accept, decline, or tentatively accept meeting invites with a single keypress. +- **📧 Standards-Compliant Replies**: Sends RFC 6047 (iMIP) compliant RSVP emails with proper `METHOD:REPLY` and `PARTSTAT` updates. +- **💾 Cache Persistence**: Calendar invite details are preserved when emails are cached for offline viewing. + +## How It Works + +When you open an email containing a calendar invite, Matcha parses the `.ics` data and renders an event card above the email body: + +``` +╔══════════════════════════════════════════╗ +║ 📅 Meeting Invite ║ +║ ║ +║ Title: Weekly Standup ║ +║ When: Mon Apr 20, 2026, 10:00 AM - ║ +║ 10:30 AM ║ +║ Where: Conference Room B ║ +║ Organizer: alice@example.com ║ +║ ║ +║ Press 1:Accept 2:Decline 3:Tentative ║ +╚══════════════════════════════════════════╝ +``` + +## Keybindings + +| Key | Action | +|-----|--------| +| `1` | Accept the invite | +| `2` | Decline the invite | +| `3` | Tentatively accept the invite | + +These keybindings are only active when a calendar invite is detected in the current email. + +## RSVP Details + +When you press an RSVP key, Matcha: + +1. Generates a reply `.ics` file with your updated attendance status (`ACCEPTED`, `DECLINED`, or `TENTATIVE`). +2. Sends an email to the organizer containing: + - A plain text body with the event summary and your response. + - An inline `text/calendar; method=REPLY` part for calendar clients that process iMIP. + - An `invite.ics` file attachment as a fallback. +3. Maintains email threading via `In-Reply-To` and `References` headers. + +## Compatibility + +RSVP replies follow the iMIP standard (RFC 6047) and are processed by most calendar systems: + +| Calendar Provider | RSVP Processing | +|-------------------|-----------------| +| Microsoft Outlook / Exchange | ✅ Fully supported | +| Apple Calendar / Mail | ✅ Fully supported | +| Mozilla Thunderbird | ✅ Fully supported | +| Google Calendar | ⚠️ Limited — Google processes RSVPs through internal APIs rather than parsing incoming iMIP emails. Your reply will arrive as a regular email in the organizer's inbox but may not update your attendance status in Google Calendar. | + +## Supported Formats + +Matcha handles calendar invites delivered as: + +- `text/calendar` MIME parts (inline calendar data). +- `.ics` file attachments (`application/ics`). +- Both `REQUEST` (new invite) and `CANCEL` (cancelled event) methods. + +If an `.ics` file cannot be parsed, it falls back to being shown as a regular attachment. diff --git a/fetcher/fetcher.go b/fetcher/fetcher.go index 237987206a79c67175e9cc2870c38b22291a1d7d..074e398954c53ffb57a0efd6a521565fc4fe4e0c 100644 --- a/fetcher/fetcher.go +++ b/fetcher/fetcher.go @@ -58,16 +58,18 @@ type Attachment struct { Filename string PartID string // Keep PartID to fetch on demand Data []byte - Encoding string // Store encoding for proper decoding - MIMEType string // Full MIME type (e.g., image/png) - ContentID string // Content-ID for inline assets (e.g., cid: references) - Inline bool // True when the part is meant to be displayed inline - IsSMIMESignature bool // True if this attachment is an S/MIME signature - SMIMEVerified bool // True if the S/MIME signature was verified successfully - IsSMIMEEncrypted bool // True if the S/MIME content was successfully decrypted - IsPGPSignature bool // True if this attachment is a PGP signature - PGPVerified bool // True if the PGP signature was verified successfully - IsPGPEncrypted bool // True if the PGP content was successfully decrypted + Encoding string // Store encoding for proper decoding + MIMEType string // Full MIME type (e.g., image/png) + ContentID string // Content-ID for inline assets (e.g., cid: references) + Inline bool // True when the part is meant to be displayed inline + IsSMIMESignature bool // True if this attachment is an S/MIME signature + SMIMEVerified bool // True if the S/MIME signature was verified successfully + IsSMIMEEncrypted bool // True if the S/MIME content was successfully decrypted + IsPGPSignature bool // True if this attachment is a PGP signature + PGPVerified bool // True if the PGP signature was verified successfully + IsPGPEncrypted bool // True if the PGP content was successfully decrypted + IsCalendarInvite bool // True if this attachment is a calendar invite (.ics) + CalendarEvent interface{} // Parsed calendar event (calendar.Event pointer) } type Email struct { @@ -960,6 +962,22 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint } } attachments = append(attachments, att) + } else if mimeType == "text/calendar" || strings.HasSuffix(strings.ToLower(filename), ".ics") { + // === CALENDAR INVITE DETECTION === + att := Attachment{ + Filename: filename, + PartID: partID, + Encoding: part.Encoding, + MIMEType: mimeType, + IsCalendarInvite: true, + } + + // Fetch and parse calendar data + if data, err := fetchInlinePart(partID, part.Encoding); err == nil { + att.Data = data + // Parse will be done lazily in calendar package when needed + } + attachments = append(attachments, att) } else if (filename != "" || isCID) && (strings.EqualFold(dispValue, "attachment") || isInline || !strings.EqualFold(part.Type, "text")) { att := Attachment{ Filename: filename, diff --git a/go.mod b/go.mod index 32093c4fddec101e8cb150962e4cb2e5c1e5180b..d796e2d4d4b3f266ae79f918ba32bf4c74228339 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ 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/ebfe/scard v0.0.0-20241214075232-7af069cabc25 github.com/emersion/go-imap/v2 v2.0.0-beta.8 github.com/emersion/go-message v0.18.2 diff --git a/go.sum b/go.sum index 7b7f3f5d358c484540aa4aa3f2957db474bc4c01..7e6ef4f395dc97036fdf0acbf9ba4a5857e3c3b6 100644 --- a/go.sum +++ b/go.sum @@ -19,6 +19,8 @@ github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= +github.com/arran4/golang-ical v0.3.5 h1:bbz6ld4dC+MmCKiFfOd6SkmIGnhNMBACZ485ULh7p9A= +github.com/arran4/golang-ical v0.3.5/go.mod h1:OnguFgjN0Hmx8jzpmWcC+AkHio94ujmLHKoaef7xQh8= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= diff --git a/main.go b/main.go index d25023bc34dc180563e39704bcec58fcecbf9190..b2ae5ae2dec51c20180234568c37cab2bff0914a 100644 --- a/main.go +++ b/main.go @@ -27,6 +27,7 @@ import ( _ "github.com/floatpane/matcha/backend/imap" _ "github.com/floatpane/matcha/backend/jmap" _ "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/config" @@ -933,7 +934,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Convert cached attachments back to fetcher.Attachment var attachments []fetcher.Attachment for _, ca := range cached.Attachments { - attachments = append(attachments, fetcher.Attachment{ + att := fetcher.Attachment{ Filename: ca.Filename, PartID: ca.PartID, Encoding: ca.Encoding, @@ -943,7 +944,12 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { IsSMIMESignature: ca.IsSMIMESignature, SMIMEVerified: ca.SMIMEVerified, IsSMIMEEncrypted: ca.IsSMIMEEncrypted, - }) + IsCalendarInvite: ca.IsCalendarInvite, + } + if ca.IsCalendarInvite && len(ca.CalendarData) > 0 { + att.Data = ca.CalendarData + } + attachments = append(attachments, att) } return m, func() tea.Msg { return tui.EmailBodyFetchedMsg{ @@ -977,7 +983,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } var cachedAttachments []config.CachedAttachment for _, a := range msg.Attachments { - cachedAttachments = append(cachedAttachments, config.CachedAttachment{ + ca := config.CachedAttachment{ Filename: a.Filename, PartID: a.PartID, Encoding: a.Encoding, @@ -987,7 +993,12 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { IsSMIMESignature: a.IsSMIMESignature, SMIMEVerified: a.SMIMEVerified, IsSMIMEEncrypted: a.IsSMIMEEncrypted, - }) + IsCalendarInvite: a.IsCalendarInvite, + } + if a.IsCalendarInvite && len(a.Data) > 0 { + ca.CalendarData = a.Data + } + cachedAttachments = append(cachedAttachments, ca) } _ = config.SaveEmailBody(folderForCache, config.CachedEmailBody{ UID: msg.UID, @@ -1190,6 +1201,37 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(m.current.Init(), sendEmail(account, msg)) + case tui.SendRSVPMsg: + account := m.config.GetAccountByID(msg.AccountID) + if account == nil { + m.current = tui.NewStatus("Error: account not found") + return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg { + return tui.RestoreViewMsg{} + }) + } + + m.current = tui.NewStatus("Sending RSVP...") + return m, tea.Batch(m.current.Init(), sendRSVP(account, msg)) + + case tui.RSVPResultMsg: + if msg.Err != nil { + log.Printf("Failed to send RSVP: %v", msg.Err) + m.previousModel = tui.NewChoice() + m.previousModel, _ = m.previousModel.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + m.current = tui.NewStatus(fmt.Sprintf("RSVP error: %v", msg.Err)) + return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg { + return tui.RestoreViewMsg{} + }) + } + status := fmt.Sprintf("RSVP sent: %s", msg.Response) + if strings.HasSuffix(strings.ToLower(msg.Organizer), "@gmail.com") || strings.HasSuffix(strings.ToLower(msg.Organizer), "@googlemail.com") { + status += " (Google Calendar may not auto-update — use Gmail buttons for Google events)" + } + m.current = tui.NewStatus(status) + return m, tea.Tick(3*time.Second, func(t time.Time) tea.Msg { + return tui.RestoreViewMsg{} + }) + case tui.EmailResultMsg: if msg.Err != nil { log.Printf("Failed to send email: %v", msg.Err) @@ -2028,6 +2070,56 @@ func sendEmail(account *config.Account, msg tui.SendEmailMsg) tea.Cmd { } } +func sendRSVP(account *config.Account, msg tui.SendRSVPMsg) tea.Cmd { + return func() tea.Msg { + if account == nil { + return tui.EmailResultMsg{Err: fmt.Errorf("no account configured")} + } + + // Generate RSVP .ics + rsvpICS, err := calendar.GenerateRSVP(msg.OriginalICS, account.Email, msg.Response) + if err != nil { + return tui.EmailResultMsg{Err: fmt.Errorf("generate RSVP: %w", err)} + } + + // Compose reply email + subject := fmt.Sprintf("Re: %s", msg.Event.Summary) + bodyText := fmt.Sprintf("%s: %s\n\n%s", + msg.Response, + msg.Event.Summary, + msg.Event.Start.Format("Mon Jan 2, 2006 3:04 PM")) + if msg.Event.Location != "" { + bodyText += " at " + msg.Event.Location + } + + // Send as multipart/alternative with text/calendar; method=REPLY + // This iMIP format is required for Google Calendar to recognize the RSVP + references := append(msg.References, msg.InReplyTo) + rawMsg, err := sender.SendCalendarReply( + account, + []string{msg.Event.Organizer}, + subject, + bodyText, + rsvpICS, + msg.InReplyTo, + references, + ) + + if err != nil { + return tui.RSVPResultMsg{Err: fmt.Errorf("send RSVP: %w", err), Response: msg.Response, Organizer: msg.Event.Organizer} + } + + // Append to Sent folder + if account.ServiceProvider != "gmail" { + if err := fetcher.AppendToSentMailbox(account, rawMsg); err != nil { + log.Printf("Failed to append RSVP to Sent folder: %v", err) + } + } + + return tui.RSVPResultMsg{Response: msg.Response, Organizer: msg.Event.Organizer} + } +} + func deleteEmailCmd(account *config.Account, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd { return func() tea.Msg { var err error diff --git a/sender/sender.go b/sender/sender.go index be11c154671b3faedb3aa99d2f884a70d5f80e52..872850dd24d087189195740f4b2758c2f137b79b 100644 --- a/sender/sender.go +++ b/sender/sender.go @@ -734,6 +734,193 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody return rawMsg, nil } +// SendCalendarReply sends an iMIP (RFC 6047) calendar reply. +// Google Calendar requires: +// - multipart/alternative with text/plain + text/calendar; method=REPLY +// - text/calendar part must NOT be Content-Disposition: attachment +func SendCalendarReply(account *config.Account, to []string, subject, plainBody string, icsData []byte, inReplyTo string, references []string) ([]byte, error) { + smtpServer := account.GetSMTPServer() + smtpPort := account.GetSMTPPort() + + if smtpServer == "" { + return nil, fmt.Errorf("unsupported or missing service_provider: %s", account.ServiceProvider) + } + + plainAuth := smtp.PlainAuth("", account.Email, account.Password, smtpServer) + loginAuthFallback := &loginAuth{username: account.Email, password: account.Password} + + fromHeader := account.FormatFromHeader() + + var msg bytes.Buffer + + // Headers + fmt.Fprintf(&msg, "From: %s\r\n", fromHeader) + fmt.Fprintf(&msg, "To: %s\r\n", strings.Join(to, ", ")) + fmt.Fprintf(&msg, "Subject: %s\r\n", subject) + fmt.Fprintf(&msg, "Date: %s\r\n", time.Now().Format(time.RFC1123Z)) + fmt.Fprintf(&msg, "Message-ID: %s\r\n", generateMessageID(account.GetSendAsEmail())) + fmt.Fprintf(&msg, "MIME-Version: 1.0\r\n") + + if inReplyTo != "" { + fmt.Fprintf(&msg, "In-Reply-To: %s\r\n", inReplyTo) + if len(references) > 0 { + fmt.Fprintf(&msg, "References: %s %s\r\n", strings.Join(references, " "), inReplyTo) + } else { + fmt.Fprintf(&msg, "References: %s\r\n", inReplyTo) + } + } + + // Build multipart/mixed containing: + // multipart/alternative (text/plain + text/calendar inline) + // + attached .ics file + // Gmail needs both the inline text/calendar AND the .ics attachment + var outerMsg bytes.Buffer + outerWriter := multipart.NewWriter(&outerMsg) + + fmt.Fprintf(&msg, "Content-Type: multipart/mixed; boundary=\"%s\"\r\n\r\n", outerWriter.Boundary()) + + // multipart/alternative part (text/plain + text/calendar) + altHeader := textproto.MIMEHeader{} + var altMsg bytes.Buffer + altWriter := multipart.NewWriter(&altMsg) + altHeader.Set("Content-Type", fmt.Sprintf("multipart/alternative; boundary=\"%s\"", altWriter.Boundary())) + + altPart, err := outerWriter.CreatePart(altHeader) + if err != nil { + return nil, err + } + + // text/plain part + plainHeader := textproto.MIMEHeader{} + plainHeader.Set("Content-Type", "text/plain; charset=UTF-8") + plainHeader.Set("Content-Transfer-Encoding", "quoted-printable") + plainPart, err := altWriter.CreatePart(plainHeader) + if err != nil { + return nil, err + } + qp := quotedprintable.NewWriter(plainPart) + fmt.Fprint(qp, plainBody) + qp.Close() + + // text/calendar inline part (Outlook/Mac Mail use this) + calHeader := textproto.MIMEHeader{} + calHeader.Set("Content-Type", "text/calendar; charset=UTF-8; method=REPLY") + calHeader.Set("Content-Transfer-Encoding", "base64") + calPart, err := altWriter.CreatePart(calHeader) + if err != nil { + return nil, err + } + calPart.Write([]byte(clib.WrapBase64(base64.StdEncoding.EncodeToString(icsData)))) + + altWriter.Close() + altPart.Write(altMsg.Bytes()) + + // .ics file attachment (Gmail uses this) + attachHeader := textproto.MIMEHeader{} + attachHeader.Set("Content-Type", "application/ics; name=\"invite.ics\"") + attachHeader.Set("Content-Disposition", "attachment; filename=\"invite.ics\"") + attachHeader.Set("Content-Transfer-Encoding", "base64") + attachPart, err := outerWriter.CreatePart(attachHeader) + if err != nil { + return nil, err + } + attachPart.Write([]byte(clib.WrapBase64(base64.StdEncoding.EncodeToString(icsData)))) + + outerWriter.Close() + msg.Write(outerMsg.Bytes()) + + // Send via SMTP + addr := fmt.Sprintf("%s:%d", smtpServer, smtpPort) + tlsConfig := &tls.Config{ + ServerName: smtpServer, + InsecureSkipVerify: account.Insecure, + } + + var c *smtp.Client + + if smtpPort == 465 { + conn, err := tls.Dial("tcp", addr, tlsConfig) + if err != nil { + return nil, err + } + c, err = smtp.NewClient(conn, smtpServer) + if err != nil { + conn.Close() + return nil, err + } + } else { + var err error + c, err = smtp.Dial(addr) + if err != nil { + return nil, err + } + } + defer c.Close() + + if err = c.Hello("localhost"); err != nil { + return nil, err + } + + if smtpPort != 465 { + if ok, _ := c.Extension("STARTTLS"); ok { + if err = c.StartTLS(tlsConfig); err != nil { + return nil, err + } + } + } + + if ok, mechs := c.Extension("AUTH"); ok { + mechList := strings.ToUpper(mechs) + if account.IsOAuth2() { + token, tokenErr := config.GetOAuth2Token(account.Email) + if tokenErr != nil { + return nil, fmt.Errorf("oauth2: %w", tokenErr) + } + err = c.Auth(&xoauth2Auth{username: account.Email, token: token}) + } else if strings.Contains(mechList, "PLAIN") { + err = c.Auth(plainAuth) + } else if strings.Contains(mechList, "LOGIN") { + err = c.Auth(loginAuthFallback) + } else { + err = c.Auth(plainAuth) + } + if err != nil { + return nil, err + } + } + + if err = c.Mail(account.GetFetchEmail()); err != nil { + return nil, err + } + for _, r := range to { + if err = c.Rcpt(r); err != nil { + return nil, err + } + } + + w, err := c.Data() + if err != nil { + return nil, err + } + _, err = w.Write(msg.Bytes()) + if err != nil { + return nil, err + } + err = w.Close() + if err != nil { + return nil, err + } + + rawMsg := make([]byte, len(msg.Bytes())) + copy(rawMsg, msg.Bytes()) + + if err := c.Quit(); err != nil { + return nil, err + } + + return rawMsg, nil +} + // signEmailPGP signs the message payload with PGP and returns a multipart/signed message. // Supports both file-based keys and YubiKey hardware tokens. func signEmailPGP(payload []byte, account *config.Account) ([]byte, error) { diff --git a/testdata/invites/simple.ics b/testdata/invites/simple.ics new file mode 100644 index 0000000000000000000000000000000000000000..c96f4d50d772059b4ef411cfa09014ccd8a4e5ee --- /dev/null +++ b/testdata/invites/simple.ics @@ -0,0 +1,18 @@ +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 d173d26522ef2d36a5ae3b9a66b1e7660a4aeffe..573aec973083b24f49ca31f552b51726e3708b10 100644 --- a/tui/email_view.go +++ b/tui/email_view.go @@ -5,10 +5,12 @@ import ( "fmt" "os" "strings" + "time" "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" + "github.com/floatpane/matcha/calendar" "github.com/floatpane/matcha/fetcher" "github.com/floatpane/matcha/theme" "github.com/floatpane/matcha/view" @@ -45,6 +47,9 @@ type EmailView struct { imagePlacements []view.ImagePlacement pluginStatus string pluginKeyBindings []PluginKeyBinding + hasCalendarInvite bool + calendarEvent *calendar.Event + originalICSData []byte } func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox MailboxKind, disableImages bool) *EmailView { @@ -55,6 +60,8 @@ func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox Ma pgpTrusted := false isPGPEncrypted := false var filteredAtts []fetcher.Attachment + var calendarEvent *calendar.Event + var originalICSData []byte for _, att := range email.Attachments { if att.Filename == "smime-status.internal" { @@ -79,6 +86,15 @@ func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox Ma pgpTrusted = att.PGPVerified } // Skip UI rendering + } else if att.IsCalendarInvite { + // Parse calendar invite if not already parsed + if len(att.Data) > 0 && calendarEvent == nil { + if event, err := calendar.ParseICS(att.Data); err == nil { + calendarEvent = event + originalICSData = att.Data + } + } + // Don't show .ics in regular attachment list } else { filteredAtts = append(filteredAtts, att) } @@ -105,28 +121,37 @@ func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox Ma attachmentHeight = len(email.Attachments) + 2 } + // Account for calendar card height + calendarHeight := 0 + if calendarEvent != nil { + calendarHeight = 10 // Approximate height for calendar card + } + // Build viewport with initial size and set wrapped content. vp := viewport.New() vp.SetWidth(width) - vp.SetHeight(height - headerHeight - attachmentHeight) + vp.SetHeight(height - headerHeight - attachmentHeight - calendarHeight) wrapped := wrapBodyToWidth(body, vp.Width()) vp.SetContent(wrapped + "\n") return &EmailView{ - viewport: vp, - email: email, - emailIndex: emailIndex, - accountID: email.AccountID, - mailbox: mailbox, - disableImages: disableImages, - showImages: showImages, - isSMIME: isSMIME, - smimeTrusted: smimeTrusted, - isEncrypted: isEncrypted, - isPGP: isPGP, - pgpTrusted: pgpTrusted, - isPGPEncrypted: isPGPEncrypted, - imagePlacements: placements, + viewport: vp, + email: email, + emailIndex: emailIndex, + accountID: email.AccountID, + mailbox: mailbox, + disableImages: disableImages, + showImages: showImages, + isSMIME: isSMIME, + smimeTrusted: smimeTrusted, + isEncrypted: isEncrypted, + isPGP: isPGP, + pgpTrusted: pgpTrusted, + isPGPEncrypted: isPGPEncrypted, + imagePlacements: placements, + hasCalendarInvite: calendarEvent != nil, + calendarEvent: calendarEvent, + originalICSData: originalICSData, } } @@ -221,6 +246,29 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, func() tea.Msg { return ArchiveEmailMsg{UID: uid, AccountID: accountID, Mailbox: m.mailbox} } + case "1", "2", "3": + if m.hasCalendarInvite && m.calendarEvent != nil { + var response string + switch msg.String() { + case "1": + response = "ACCEPTED" + case "2": + response = "DECLINED" + case "3": + response = "TENTATIVE" + } + + return m, func() tea.Msg { + return SendRSVPMsg{ + OriginalICS: m.originalICSData, + Event: m.calendarEvent, + Response: response, + AccountID: m.accountID, + InReplyTo: m.email.MessageID, + References: m.email.References, + } + } + } case "tab": if len(m.email.Attachments) > 0 { m.focusOnAttachments = true @@ -346,7 +394,16 @@ func (m *EmailView) View() tea.View { } } + // Render calendar invite card if present + var calendarView string + if m.hasCalendarInvite && m.calendarEvent != nil { + calendarView = renderCalendarInvite(m.calendarEvent) + } + // m.viewport.View() returns a string in Bubbles v2 viewport + if calendarView != "" { + return tea.NewView(fmt.Sprintf("%s\n%s\n%s\n%s\n%s", styledHeader, calendarView, m.viewport.View(), attachmentView, help)) + } return tea.NewView(fmt.Sprintf("%s\n%s\n%s\n%s", styledHeader, m.viewport.View(), attachmentView, help)) } @@ -387,3 +444,57 @@ func wrapBodyToWidth(body string, width int) string { func (m *EmailView) GetEmail() fetcher.Email { return m.email } + +// renderCalendarInvite renders a calendar invite card +func renderCalendarInvite(event *calendar.Event) string { + style := lipgloss.NewStyle(). + Border(lipgloss.DoubleBorder()). + BorderForeground(theme.ActiveTheme.Accent). + Padding(1, 2). + MarginTop(1). + MarginBottom(1) + + var b strings.Builder + b.WriteString("📅 Meeting Invite\n\n") + b.WriteString(fmt.Sprintf("Title: %s\n", event.Summary)) + b.WriteString(fmt.Sprintf("When: %s\n", formatEventTime(event.Start, event.End))) + + if event.Location != "" { + b.WriteString(fmt.Sprintf("Where: %s\n", event.Location)) + } + + b.WriteString(fmt.Sprintf("Organizer: %s\n", event.Organizer)) + + if event.Description != "" { + desc := truncateString(event.Description, 100) + b.WriteString(fmt.Sprintf("\n%s\n", desc)) + } + + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle().Italic(true).Render("Press 1:Accept 2:Decline 3:Tentative")) + + return style.Render(b.String()) +} + +// formatEventTime formats event start/end times +func formatEventTime(start, end time.Time) string { + if start.Format("2006-01-02") == end.Format("2006-01-02") { + // Same day + return fmt.Sprintf("%s, %s - %s", + start.Format("Mon Jan 2, 2006"), + start.Format("3:04 PM"), + end.Format("3:04 PM")) + } + // Multi-day + return fmt.Sprintf("%s - %s", + start.Format("Mon Jan 2 3:04 PM"), + end.Format("Mon Jan 2 3:04 PM")) +} + +// truncateString truncates string to maxLen +func truncateString(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "..." +} diff --git a/tui/messages.go b/tui/messages.go index bb3921a6ea85ab07fcb12b48ada1978c603bdfb5..08698deede2969859c93bf90944ba9eac3f2cee0 100644 --- a/tui/messages.go +++ b/tui/messages.go @@ -1,6 +1,7 @@ package tui import ( + "github.com/floatpane/matcha/calendar" "github.com/floatpane/matcha/config" "github.com/floatpane/matcha/fetcher" ) @@ -502,3 +503,20 @@ type SecureModeEnabledMsg struct { type SecureModeDisabledMsg struct { Err error } + +// SendRSVPMsg signals that user wants to send RSVP to calendar invite +type SendRSVPMsg struct { + OriginalICS []byte + Event *calendar.Event + Response string // "ACCEPTED", "DECLINED", "TENTATIVE" + AccountID string + InReplyTo string + References []string +} + +// RSVPResultMsg signals that RSVP was sent (or failed) +type RSVPResultMsg struct { + Err error + Response string // "ACCEPTED", "DECLINED", "TENTATIVE" + Organizer string // organizer email for Google Calendar note +}