mirror of
https://github.com/hanzoskill/sogcli.git
synced 2026-10-30 21:03:29 +00:00
507 lines
13 KiB
Go
507 lines
13 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/visionik/sogcli/internal/config"
|
|
"github.com/visionik/sogcli/internal/itip"
|
|
"github.com/visionik/sogcli/internal/smtp"
|
|
)
|
|
|
|
// InviteCmd handles meeting invitation operations.
|
|
type InviteCmd struct {
|
|
Send InviteSendCmd `cmd:"" help:"Send a meeting invitation"`
|
|
Reply InviteReplyCmd `cmd:"" help:"Reply to a meeting invitation"`
|
|
Cancel InviteCancelCmd `cmd:"" help:"Cancel a meeting"`
|
|
Parse InviteParseCmd `cmd:"" help:"Parse an .ics file"`
|
|
Preview InvitePreviewCmd `cmd:"" help:"Preview invite without sending"`
|
|
}
|
|
|
|
// InviteSendCmd sends a meeting invitation.
|
|
type InviteSendCmd struct {
|
|
Summary string `arg:"" help:"Meeting title/summary"`
|
|
Attendees []string `arg:"" help:"Attendee email addresses"`
|
|
Start string `help:"Start time (YYYY-MM-DDTHH:MM or 'tomorrow 2pm')" required:""`
|
|
Duration string `help:"Duration (e.g., 1h, 30m, 1h30m)" default:"1h"`
|
|
End string `help:"End time (alternative to duration)"`
|
|
Location string `help:"Meeting location" short:"l"`
|
|
Description string `help:"Meeting description" short:"d"`
|
|
Organizer string `help:"Organizer name"`
|
|
}
|
|
|
|
// Run executes the invite send command.
|
|
func (c *InviteSendCmd) Run(root *Root) error {
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to load config: %w", err)
|
|
}
|
|
|
|
email := root.Account
|
|
if email == "" {
|
|
email = cfg.DefaultAccount
|
|
}
|
|
if email == "" {
|
|
return fmt.Errorf("no account specified")
|
|
}
|
|
|
|
// Parse start time
|
|
start, _, err := parseDateTime(c.Start)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid start time: %w", err)
|
|
}
|
|
|
|
// Calculate end time
|
|
var end time.Time
|
|
if c.End != "" {
|
|
end, _, err = parseDateTime(c.End)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid end time: %w", err)
|
|
}
|
|
} else {
|
|
dur, err := time.ParseDuration(c.Duration)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid duration: %w", err)
|
|
}
|
|
end = start.Add(dur)
|
|
}
|
|
|
|
// Build invite
|
|
inv := &itip.Invite{
|
|
Method: itip.MethodRequest,
|
|
UID: itip.GenerateUID(getDomain(email)),
|
|
Summary: c.Summary,
|
|
Description: c.Description,
|
|
Location: c.Location,
|
|
Start: start,
|
|
End: end,
|
|
Organizer: itip.Participant{
|
|
Email: email,
|
|
Name: c.Organizer,
|
|
},
|
|
Sequence: 0,
|
|
Created: time.Now().UTC(),
|
|
}
|
|
|
|
for _, att := range c.Attendees {
|
|
inv.Attendees = append(inv.Attendees, itip.Participant{
|
|
Email: att,
|
|
RSVP: true,
|
|
})
|
|
}
|
|
|
|
// Generate iCalendar
|
|
icsData, err := itip.CreateInvite(inv)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create invite: %w", err)
|
|
}
|
|
|
|
// Send via SMTP
|
|
if err := sendInviteEmail(cfg, email, inv, icsData); err != nil {
|
|
return fmt.Errorf("failed to send invite: %w", err)
|
|
}
|
|
|
|
if root.JSON {
|
|
fmt.Printf(`{"uid":"%s","summary":"%s","start":"%s","end":"%s","attendees":%d}`+"\n",
|
|
inv.UID, inv.Summary, inv.Start.Format(time.RFC3339), inv.End.Format(time.RFC3339), len(inv.Attendees))
|
|
return nil
|
|
}
|
|
|
|
fmt.Printf("Sent meeting invite: %s\n", inv.Summary)
|
|
fmt.Printf(" UID: %s\n", inv.UID)
|
|
fmt.Printf(" When: %s - %s\n", inv.Start.Format("Mon Jan 2 15:04"), inv.End.Format("15:04"))
|
|
if inv.Location != "" {
|
|
fmt.Printf(" Where: %s\n", inv.Location)
|
|
}
|
|
fmt.Printf(" Attendees: %s\n", strings.Join(c.Attendees, ", "))
|
|
return nil
|
|
}
|
|
|
|
// InviteReplyCmd replies to a meeting invitation.
|
|
type InviteReplyCmd struct {
|
|
File string `arg:"" help:".ics file or - for stdin"`
|
|
Status string `help:"Response: accept, decline, tentative" required:"" enum:"accept,decline,tentative"`
|
|
Comment string `help:"Optional comment with response"`
|
|
}
|
|
|
|
// Run executes the invite reply command.
|
|
func (c *InviteReplyCmd) Run(root *Root) error {
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to load config: %w", err)
|
|
}
|
|
|
|
email := root.Account
|
|
if email == "" {
|
|
email = cfg.DefaultAccount
|
|
}
|
|
if email == "" {
|
|
return fmt.Errorf("no account specified")
|
|
}
|
|
|
|
// Read .ics file
|
|
var data []byte
|
|
if c.File == "-" {
|
|
data, err = io.ReadAll(os.Stdin)
|
|
} else {
|
|
data, err = os.ReadFile(c.File)
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read file: %w", err)
|
|
}
|
|
|
|
// Parse invite
|
|
inv, err := itip.ParseInvite(data)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse invite: %w", err)
|
|
}
|
|
|
|
// Determine status
|
|
var status itip.ParticipantStatus
|
|
switch c.Status {
|
|
case "accept":
|
|
status = itip.StatusAccepted
|
|
case "decline":
|
|
status = itip.StatusDeclined
|
|
case "tentative":
|
|
status = itip.StatusTentative
|
|
}
|
|
|
|
// Create reply
|
|
resp := &itip.Response{
|
|
UID: inv.UID,
|
|
Attendee: itip.Participant{
|
|
Email: email,
|
|
Status: status,
|
|
},
|
|
Organizer: inv.Organizer,
|
|
Status: status,
|
|
Comment: c.Comment,
|
|
Sequence: inv.Sequence,
|
|
}
|
|
|
|
replyData, err := itip.CreateReply(resp)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create reply: %w", err)
|
|
}
|
|
|
|
// Send reply to organizer
|
|
if err := sendReplyEmail(cfg, email, inv, resp, replyData); err != nil {
|
|
return fmt.Errorf("failed to send reply: %w", err)
|
|
}
|
|
|
|
fmt.Printf("Sent %s reply to: %s\n", c.Status, inv.Organizer.Email)
|
|
fmt.Printf(" Meeting: %s\n", inv.Summary)
|
|
return nil
|
|
}
|
|
|
|
// InviteCancelCmd cancels a meeting.
|
|
type InviteCancelCmd struct {
|
|
UID string `arg:"" help:"Meeting UID to cancel"`
|
|
Attendees []string `arg:"" help:"Attendee emails to notify"`
|
|
}
|
|
|
|
// Run executes the invite cancel command.
|
|
func (c *InviteCancelCmd) Run(root *Root) error {
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to load config: %w", err)
|
|
}
|
|
|
|
email := root.Account
|
|
if email == "" {
|
|
email = cfg.DefaultAccount
|
|
}
|
|
if email == "" {
|
|
return fmt.Errorf("no account specified")
|
|
}
|
|
|
|
organizer := itip.Participant{Email: email}
|
|
var attendees []itip.Participant
|
|
for _, att := range c.Attendees {
|
|
attendees = append(attendees, itip.Participant{Email: att})
|
|
}
|
|
|
|
cancelData, err := itip.CreateCancel(c.UID, organizer, attendees, 1)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create cancel: %w", err)
|
|
}
|
|
|
|
// Send cancel to all attendees
|
|
if err := sendCancelEmail(cfg, email, c.UID, c.Attendees, cancelData); err != nil {
|
|
return fmt.Errorf("failed to send cancel: %w", err)
|
|
}
|
|
|
|
fmt.Printf("Sent cancellation for meeting: %s\n", c.UID)
|
|
fmt.Printf(" Notified: %s\n", strings.Join(c.Attendees, ", "))
|
|
return nil
|
|
}
|
|
|
|
// InviteParseCmd parses an .ics file.
|
|
type InviteParseCmd struct {
|
|
File string `arg:"" help:".ics file or - for stdin"`
|
|
}
|
|
|
|
// Run executes the invite parse command.
|
|
func (c *InviteParseCmd) Run(root *Root) error {
|
|
var data []byte
|
|
var err error
|
|
if c.File == "-" {
|
|
data, err = io.ReadAll(os.Stdin)
|
|
} else {
|
|
data, err = os.ReadFile(c.File)
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read file: %w", err)
|
|
}
|
|
|
|
inv, err := itip.ParseInvite(data)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse: %w", err)
|
|
}
|
|
|
|
if root.JSON {
|
|
fmt.Printf(`{"method":"%s","uid":"%s","summary":"%s","start":"%s","end":"%s","organizer":"%s","attendees":%d}`+"\n",
|
|
inv.Method, inv.UID, inv.Summary,
|
|
inv.Start.Format(time.RFC3339), inv.End.Format(time.RFC3339),
|
|
inv.Organizer.Email, len(inv.Attendees))
|
|
return nil
|
|
}
|
|
|
|
fmt.Printf("Method: %s\n", inv.Method)
|
|
fmt.Printf("UID: %s\n", inv.UID)
|
|
fmt.Printf("Summary: %s\n", inv.Summary)
|
|
fmt.Printf("Start: %s\n", inv.Start.Format("Mon Jan 2, 2006 15:04 MST"))
|
|
fmt.Printf("End: %s\n", inv.End.Format("Mon Jan 2, 2006 15:04 MST"))
|
|
if inv.Location != "" {
|
|
fmt.Printf("Location: %s\n", inv.Location)
|
|
}
|
|
if inv.Description != "" {
|
|
fmt.Printf("Desc: %s\n", inv.Description)
|
|
}
|
|
fmt.Printf("Organizer: %s", inv.Organizer.Email)
|
|
if inv.Organizer.Name != "" {
|
|
fmt.Printf(" (%s)", inv.Organizer.Name)
|
|
}
|
|
fmt.Println()
|
|
if len(inv.Attendees) > 0 {
|
|
fmt.Println("Attendees:")
|
|
for _, att := range inv.Attendees {
|
|
status := string(att.Status)
|
|
if status == "" {
|
|
status = "NEEDS-ACTION"
|
|
}
|
|
fmt.Printf(" - %s [%s]", att.Email, status)
|
|
if att.Name != "" {
|
|
fmt.Printf(" (%s)", att.Name)
|
|
}
|
|
fmt.Println()
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// InvitePreviewCmd previews an invite without sending.
|
|
type InvitePreviewCmd struct {
|
|
Summary string `arg:"" help:"Meeting title/summary"`
|
|
Attendees []string `arg:"" help:"Attendee email addresses"`
|
|
Start string `help:"Start time (YYYY-MM-DDTHH:MM)" required:""`
|
|
Duration string `help:"Duration (e.g., 1h, 30m)" default:"1h"`
|
|
Location string `help:"Meeting location" short:"l"`
|
|
Description string `help:"Meeting description" short:"d"`
|
|
}
|
|
|
|
// Run executes the invite preview command.
|
|
func (c *InvitePreviewCmd) Run(root *Root) error {
|
|
email := root.Account
|
|
if email == "" {
|
|
cfg, _ := config.Load()
|
|
if cfg != nil {
|
|
email = cfg.DefaultAccount
|
|
}
|
|
}
|
|
if email == "" {
|
|
email = "[email protected]"
|
|
}
|
|
|
|
start, _, err := parseDateTime(c.Start)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid start time: %w", err)
|
|
}
|
|
|
|
dur, err := time.ParseDuration(c.Duration)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid duration: %w", err)
|
|
}
|
|
end := start.Add(dur)
|
|
|
|
inv := &itip.Invite{
|
|
Method: itip.MethodRequest,
|
|
UID: itip.GenerateUID(getDomain(email)),
|
|
Summary: c.Summary,
|
|
Description: c.Description,
|
|
Location: c.Location,
|
|
Start: start,
|
|
End: end,
|
|
Organizer: itip.Participant{Email: email},
|
|
Sequence: 0,
|
|
Created: time.Now().UTC(),
|
|
}
|
|
|
|
for _, att := range c.Attendees {
|
|
inv.Attendees = append(inv.Attendees, itip.Participant{Email: att, RSVP: true})
|
|
}
|
|
|
|
icsData, err := itip.CreateInvite(inv)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create invite: %w", err)
|
|
}
|
|
|
|
fmt.Println(string(icsData))
|
|
return nil
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
func getDomain(email string) string {
|
|
parts := strings.Split(email, "@")
|
|
if len(parts) == 2 {
|
|
return parts[1]
|
|
}
|
|
return "sog.local"
|
|
}
|
|
|
|
func sendInviteEmail(cfg *config.Config, from string, inv *itip.Invite, icsData []byte) error {
|
|
acct, err := cfg.GetAccount(from)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
password, err := cfg.GetPasswordForProtocol(from, config.ProtocolSMTP)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
client, err := smtp.Connect(smtp.Config{
|
|
Host: acct.SMTP.Host,
|
|
Port: acct.SMTP.Port,
|
|
Email: from,
|
|
Password: password,
|
|
StartTLS: acct.SMTP.StartTLS,
|
|
TLS: acct.SMTP.TLS,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer client.Close()
|
|
|
|
// Build recipient list
|
|
var to []string
|
|
for _, att := range inv.Attendees {
|
|
to = append(to, att.Email)
|
|
}
|
|
|
|
// Create message with calendar attachment
|
|
msg := &smtp.Message{
|
|
From: from,
|
|
To: to,
|
|
Subject: fmt.Sprintf("Meeting Invitation: %s", inv.Summary),
|
|
Body: fmt.Sprintf("You have been invited to: %s\n\nWhen: %s - %s\nWhere: %s\n\n%s",
|
|
inv.Summary,
|
|
inv.Start.Format("Mon Jan 2, 2006 15:04"),
|
|
inv.End.Format("15:04"),
|
|
inv.Location,
|
|
inv.Description),
|
|
CalendarData: icsData,
|
|
CalendarMethod: string(inv.Method),
|
|
}
|
|
|
|
return client.Send(context.Background(), msg)
|
|
}
|
|
|
|
func sendReplyEmail(cfg *config.Config, from string, inv *itip.Invite, resp *itip.Response, replyData []byte) error {
|
|
acct, err := cfg.GetAccount(from)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
password, err := cfg.GetPasswordForProtocol(from, config.ProtocolSMTP)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
client, err := smtp.Connect(smtp.Config{
|
|
Host: acct.SMTP.Host,
|
|
Port: acct.SMTP.Port,
|
|
Email: from,
|
|
Password: password,
|
|
StartTLS: acct.SMTP.StartTLS,
|
|
TLS: acct.SMTP.TLS,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer client.Close()
|
|
|
|
statusWord := "responded to"
|
|
switch resp.Status {
|
|
case itip.StatusAccepted:
|
|
statusWord = "accepted"
|
|
case itip.StatusDeclined:
|
|
statusWord = "declined"
|
|
case itip.StatusTentative:
|
|
statusWord = "tentatively accepted"
|
|
}
|
|
|
|
msg := &smtp.Message{
|
|
From: from,
|
|
To: []string{inv.Organizer.Email},
|
|
Subject: fmt.Sprintf("Re: %s", inv.Summary),
|
|
Body: fmt.Sprintf("%s has %s your meeting invitation: %s", from, statusWord, inv.Summary),
|
|
CalendarData: replyData,
|
|
CalendarMethod: string(itip.MethodReply),
|
|
}
|
|
|
|
return client.Send(context.Background(), msg)
|
|
}
|
|
|
|
func sendCancelEmail(cfg *config.Config, from string, uid string, attendees []string, cancelData []byte) error {
|
|
acct, err := cfg.GetAccount(from)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
password, err := cfg.GetPasswordForProtocol(from, config.ProtocolSMTP)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
client, err := smtp.Connect(smtp.Config{
|
|
Host: acct.SMTP.Host,
|
|
Port: acct.SMTP.Port,
|
|
Email: from,
|
|
Password: password,
|
|
StartTLS: acct.SMTP.StartTLS,
|
|
TLS: acct.SMTP.TLS,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer client.Close()
|
|
|
|
msg := &smtp.Message{
|
|
From: from,
|
|
To: attendees,
|
|
Subject: "Meeting Cancelled",
|
|
Body: fmt.Sprintf("The meeting has been cancelled.\n\nUID: %s", uid),
|
|
CalendarData: cancelData,
|
|
CalendarMethod: string(itip.MethodCancel),
|
|
}
|
|
|
|
return client.Send(context.Background(), msg)
|
|
}
|