Detailed changes
@@ -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)
+}
@@ -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)
+ }
+ })
+ }
+}
@@ -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.
@@ -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.
@@ -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,
@@ -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
@@ -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=
@@ -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
@@ -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) {
@@ -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
@@ -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] + "..."
+}
@@ -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
+}