feat: calendar events support (#678)

Drew Smirnoff created

Change summary

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(-)

Detailed changes

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)
+}

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)
+			}
+		})
+	}
+}

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.

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.

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,

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

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=

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

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) {

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

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] + "..."
+}

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
+}