From dcf7b07db41f7704409a764dd29f4d5604be54c4 Mon Sep 17 00:00:00 2001 From: drew Date: Mon, 28 Jul 2025 11:23:01 +0400 Subject: [PATCH] feat: new UI for login + better file structure --- cmd/gmail/send.go | 49 --------- cmd/icloud/send.go | 44 -------- config/config.go | 65 ++++++++++++ main.go | 243 +++++---------------------------------------- sender/sender.go | 32 ++++++ tui/composer.go | 146 +++++++++++++++++++++++++++ tui/login.go | 142 ++++++++++++++++++++++++++ tui/styles.go | 18 ++++ 8 files changed, 427 insertions(+), 312 deletions(-) delete mode 100644 cmd/gmail/send.go delete mode 100644 cmd/icloud/send.go create mode 100644 config/config.go create mode 100644 sender/sender.go create mode 100644 tui/composer.go create mode 100644 tui/login.go create mode 100644 tui/styles.go diff --git a/cmd/gmail/send.go b/cmd/gmail/send.go deleted file mode 100644 index 5103e2659892ce4e752bdb1af11eb3f3e5dce8ef..0000000000000000000000000000000000000000 --- a/cmd/gmail/send.go +++ /dev/null @@ -1,49 +0,0 @@ -package gmail - -import ( - "fmt" - "log" - "net/smtp" -) - -func main(from_email string, password string, to_email []string, subject string, body string) { - - // Requires a Google App Password for your Gmail account. - // You must generate an App Password. - // - // How to generate a Google App Password: - // 1. Go to your Google Account at https://myaccount.google.com/ - // 2. Select "Security" from the left navigation panel. - // 3. Under "How you sign in to Google," click on "2-Step Verification" and ensure it is turned ON. - // 4. Go back to the Security page and click on "App passwords." You may need to sign in again. - // 5. At the bottom, click "Select app" and choose "Mail." - // 6. Click "Select device" and choose "Other (Custom name)." - // 7. Give it a name (e.g., "Go Email Script") and click "Generate." - // FIXME: 8. Copy the 16-character password and paste it into the config. - - - // Gmail SMTP server settings. - // DO NOT EDIT - smtpHost := "smtp.gmail.com" - smtpPort := "587" - - // --- Email Content --- - // The message must be formatted according to RFC 822. - // The "To", "From", and "Subject" headers are separated from the body by a blank line. - message := []byte("To: " + to_email[0] + "\r\n" + - "Subject: " + subject + "\r\n" + - body + "\r\n") - - // --- Authentication --- - // Create an authentication object. - auth := smtp.PlainAuth("", from_email, password, smtpHost) - - // --- Sending the Email --- - // Connect to the SMTP server, authenticate, and send the email. - err := smtp.SendMail(smtpHost+":"+smtpPort, auth, from_email, to_email, message) - if err != nil { - log.Fatalf("Failed to send email: %s", err) - } - - fmt.Println("Email sent successfully!") -} diff --git a/cmd/icloud/send.go b/cmd/icloud/send.go deleted file mode 100644 index cc5c83fe5bdaedab43857fc551dec1cb61e7e01c..0000000000000000000000000000000000000000 --- a/cmd/icloud/send.go +++ /dev/null @@ -1,44 +0,0 @@ -package icloud - -import ( - "fmt" - "log" - "net/smtp" -) - -func main(to_email []string, password string, subject string, body string, from_email string) { - // --- Configuration --- - // Your full iCloud email address. - - - // Requires an app-specific password for your iCloud account. - // - // How to generate an app-specific password: - // 1. Go to https://appleid.apple.com - // 2. Sign in with your Apple ID and password. - // 3. Under "Sign-In and Security", click on "App-Specific Passwords". - // 4. Click "Generate an app-specific password" or the "+" button. - // 5. Enter a name for the password (e.g., "Go Email Script") and click "Create". - // FIXME: 6. Copy the generated password and paste it in configs. - - - // iCloud SMTP server settings. - smtpHost := "smtp.mail.me.com" - smtpPort := "587" - - // --- Email Content --- - message := []byte(subject + body) - - // --- Authentication --- - // Create an authentication object. - auth := smtp.PlainAuth("", from_email, password, smtpHost) - - // --- Sending the Email --- - // Connect to the SMTP server, authenticate, and send the email. - err := smtp.SendMail(smtpHost+":"+smtpPort, auth, from_email, to_email, message) - if err != nil { - log.Fatalf("Failed to send email: %s", err) - } - - fmt.Println("Email sent successfully!") -} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000000000000000000000000000000000000..beddd99e7f4ce3293029aa96400ecba370e0b710 --- /dev/null +++ b/config/config.go @@ -0,0 +1,65 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" +) + +// Config stores the user's email configuration. +type Config struct { + ServiceProvider string `json:"service_provider"` + Email string `json:"email"` + Password string `json:"password"` +} + +// configDir returns the path to the configuration directory. +func configDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".config", "email-cli"), nil +} + +// configFile returns the full path to the configuration file. +func configFile() (string, error) { + dir, err := configDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "config.json"), nil +} + +// SaveConfig saves the given configuration to the config file. +func SaveConfig(config *Config) error { + path, err := configFile() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return err + } + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0600) +} + +// LoadConfig loads the configuration from the config file. +func LoadConfig() (*Config, error) { + path, err := configFile() + if err != nil { + return nil, err + } + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var config Config + if err := json.Unmarshal(data, &config); err != nil { + return nil, err + } + return &config, nil +} \ No newline at end of file diff --git a/main.go b/main.go index f83a2e237edc8a106bf26347697c164dc9ffb83b..c47de7b39a775e1f536bebbdb74efdd6fe7bff29 100644 --- a/main.go +++ b/main.go @@ -5,230 +5,35 @@ import ( "log" "os" - "github.com/charmbracelet/bubbles/textarea" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + "github.com/andrinoff/email-cli/config" + "github.com/andrinoff/email-cli/tui" ) -// --- STYLES --- -// Define styles for different parts of the UI using lipgloss. -var ( - // Style for the main container/dialog box. - dialogBoxStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#874BFD")). - Padding(1, 0). - BorderTop(true). - BorderLeft(true). - BorderRight(true). - BorderBottom(true) - - // Style for the text labels (e.g., "To:", "Subject:"). - labelStyle = lipgloss.NewStyle().Padding(0, 1) - - // Style for the help text at the bottom. - helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) - - // Style for success messages. - successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true) - - // Style for error/sending messages. - infoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true) -) - -// --- MODEL --- -// The model represents the state of our TUI application. -type model struct { - // Inputs for the email fields. - recipientInput textinput.Model - subjectInput textinput.Model - bodyArea textarea.Model - - // index tracks which input is currently focused. - // 0: recipient, 1: subject, 2: body - index int - - // State flags - sending bool // Is the email currently being "sent"? - sent bool // Has the email been "sent"? - err error // Any error that occurred. -} - -// initialModel creates the starting state of the application. -func initialModel() model { - // --- Recipient Input --- - ti := textinput.New() - ti.Placeholder = "recipient@example.com" - ti.Focus() // Start with the recipient field focused. - ti.CharLimit = 156 - ti.Width = 50 - ti.Prompt = "To: " - - // --- Subject Input --- - si := textinput.New() - si.Placeholder = "Hello there!" - si.CharLimit = 156 - si.Width = 50 - si.Prompt = "Subject: " - - // --- Body Text Area --- - ta := textarea.New() - ta.Placeholder = "Dear friend, ..." - ta.SetWidth(50) - ta.SetHeight(10) - - return model{ - recipientInput: ti, - subjectInput: si, - bodyArea: ta, - index: 0, - err: nil, - } -} - -// --- BUBBLETEA METHODS --- - -// Init is the first function that will be called. It returns a command. -// We'll use it to make the cursor blink. -func (m model) Init() tea.Cmd { - return textinput.Blink -} - -// Update handles all user input and updates the model accordingly. -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - var cmds []tea.Cmd - - switch msg := msg.(type) { - // Handle key presses - case tea.KeyMsg: - switch msg.Type { - // Quit the application - case tea.KeyCtrlC, tea.KeyEsc: - return m, tea.Quit - - // Trigger the "send" action - case tea.KeyCtrlS: - // If we are not already sending, start the sending process. - if !m.sending { - m.sending = true - // In a real app, this is where you'd send the email. - // We'll return a command that simulates a network operation. - // For now, we'll just pretend it was successful after a short delay. - return m, func() tea.Msg { - // time.Sleep(time.Second * 2) // Simulate network delay - return "sent" // Send a message back on success - } - } - - // Handle Tab and Shift+Tab to navigate between fields - case tea.KeyTab, tea.KeyShiftTab: - if msg.Type == tea.KeyTab { - m.index = (m.index + 1) % 3 - } else { - m.index-- - if m.index < 0 { - m.index = 2 - } +func main() { + cfg, err := config.LoadConfig() + if err != nil { + // If config file does not exist, run the login UI + if os.IsNotExist(err) { + fmt.Println("No configuration found. Starting setup...") + newCfg, err := tui.RunLogin() + if err != nil { + log.Fatalf("Could not run login UI: %v", err) } - - // Blur all inputs - m.recipientInput.Blur() - m.subjectInput.Blur() - m.bodyArea.Blur() - - // Focus the correct input based on the index - switch m.index { - case 0: - m.recipientInput.Focus() - case 1: - m.subjectInput.Focus() - case 2: - m.bodyArea.Focus() + // If user quit the login screen without saving, exit + if newCfg == nil { + fmt.Println("\nSetup cancelled. Exiting.") + os.Exit(0) } - return m, nil - } - - // Handle the "sent" message from our simulated send command - case string: - if msg == "sent" { - m.sending = false - m.sent = true - // Quit after a short delay to show the success message. - return m, tea.Sequence( - func() tea.Msg { - // time.Sleep(time.Second) - return nil - }, - tea.Quit, - ) + cfg = newCfg + } else { + // For any other error loading the config, exit + log.Fatalf("Could not load config: %v", err) } } - // Pass the message to the focused input field for handling. - switch m.index { - case 0: - m.recipientInput, cmd = m.recipientInput.Update(msg) - case 1: - m.subjectInput, cmd = m.subjectInput.Update(msg) - case 2: - m.bodyArea, cmd = m.bodyArea.Update(msg) - } - cmds = append(cmds, cmd) - - return m, tea.Batch(cmds...) -} - -// View renders the UI based on the current model state. -func (m model) View() string { - // If the email was sent, just show a success message. - if m.sent { - return successStyle.Render("\n ✓ Email sent successfully! \n\n") - } - - // If we are "sending", show an info message. - if m.sending { - return infoStyle.Render("\n Sending email... \n\n") - } - - // Otherwise, render the form. - var s string - - s += "\n" - s += m.recipientInput.View() + "\n" - s += m.subjectInput.View() + "\n" - s += m.bodyArea.View() - - help := fmt.Sprintf("\n\n %s | %s | %s | %s \n", - "Tab: Next Field", "Esc: Quit", "Ctrl+S: Send", helpStyle.Render("Focus: "+m.focusString())) - s += helpStyle.Render(help) - - return dialogBoxStyle.Render(s) -} - -// Helper function to show which field is focused. -func (m model) focusString() string { - switch m.index { - case 0: - return "Recipient" - case 1: - return "Subject" - case 2: - return "Body" - default: - return "" - } -} - -// --- MAIN FUNCTION --- -func main() { - // Initialize the Bubble Tea program. - p := tea.NewProgram(initialModel()) - - // Run the program and handle any errors. - if _, err := p.Run(); err != nil { - log.Fatalf("Alas, there's been an error: %v", err) - os.Exit(1) + // Now that we have a config, run the main email composer UI + fmt.Printf("Logged in as %s. Starting email composer...\n", cfg.Email) + if err := tui.RunComposer(cfg); err != nil { + log.Fatalf("Application error: %v", err) } -} +} \ No newline at end of file diff --git a/sender/sender.go b/sender/sender.go new file mode 100644 index 0000000000000000000000000000000000000000..dfb1ae7e957595cee1cfd0ae49f220d378035b80 --- /dev/null +++ b/sender/sender.go @@ -0,0 +1,32 @@ +package sender + +import ( + "fmt" + "net/smtp" + + "github.com/andrinoff/email-cli/config" // Import the local config package +) + +// SendEmail sends an email using the provided configuration and content. +func SendEmail(cfg *config.Config, to []string, subject, body string) error { + var smtpHost, smtpPort string + + switch cfg.ServiceProvider { + case "gmail": + smtpHost = "smtp.gmail.com" + smtpPort = "587" + case "icloud": + smtpHost = "smtp.mail.me.com" + smtpPort = "587" + default: + return fmt.Errorf("unsupported service provider: %s", cfg.ServiceProvider) + } + + auth := smtp.PlainAuth("", cfg.Email, cfg.Password, smtpHost) + msg := []byte("To: " + to[0] + "\r\n" + + "Subject: " + subject + "\r\n" + + "\r\n" + + body) + addr := smtpHost + ":" + smtpPort + return smtp.SendMail(addr, auth, cfg.Email, to, msg) +} \ No newline at end of file diff --git a/tui/composer.go b/tui/composer.go new file mode 100644 index 0000000000000000000000000000000000000000..8ecf1199c5a52e6d169f0f660f6cbde2a06bcf4e --- /dev/null +++ b/tui/composer.go @@ -0,0 +1,146 @@ +package tui + +import ( + "fmt" + "strings" + "time" + + "github.com/andrinoff/email-cli/config" + "github.com/andrinoff/email-cli/sender" + "github.com/charmbracelet/bubbles/textarea" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +type composerModel struct { + recipientInput textinput.Model + subjectInput textinput.Model + bodyArea textarea.Model + index int + sending bool + sent bool + err error + config *config.Config +} + +func initialComposerModel(cfg *config.Config) composerModel { + ti := textinput.New() + ti.Placeholder = "recipient@example.com" + ti.Focus() + ti.CharLimit = 156 + ti.Width = 50 + ti.Prompt = "To: " + si := textinput.New() + si.Placeholder = "Hello there!" + si.CharLimit = 156 + si.Width = 50 + si.Prompt = "Subject: " + ta := textarea.New() + ta.Placeholder = "Dear friend, ..." + ta.SetWidth(50) + ta.SetHeight(10) + return composerModel{ + recipientInput: ti, + subjectInput: si, + bodyArea: ta, + index: 0, + config: cfg, + err: nil, + } +} + +func (m composerModel) Init() tea.Cmd { return textinput.Blink } + +func (m composerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyCtrlC, tea.KeyEsc: + return m, tea.Quit + case tea.KeyCtrlS: + if !m.sending { + m.sending = true + to := []string{m.recipientInput.Value()} + subject := m.subjectInput.Value() + body := m.bodyArea.Value() + return m, func() tea.Msg { + err := sender.SendEmail(m.config, to, subject, body) + if err != nil { + return err + } + return "sent" + } + } + case tea.KeyTab, tea.KeyShiftTab: + if msg.Type == tea.KeyTab { + m.index = (m.index + 1) % 3 + } else { + m.index-- + if m.index < 0 { + m.index = 2 + } + } + m.recipientInput.Blur() + m.subjectInput.Blur() + m.bodyArea.Blur() + switch m.index { + case 0: + m.recipientInput.Focus() + case 1: + m.subjectInput.Focus() + case 2: + m.bodyArea.Focus() + } + return m, tea.Batch(cmds...) + } + case string: + if msg == "sent" { + m.sending = false + m.sent = true + return m, tea.Sequence(func() tea.Msg { time.Sleep(time.Second); return nil }, tea.Quit) + } + case error: + m.sending = false + m.err = msg + return m, nil + } + var cmd tea.Cmd + switch m.index { + case 0: + m.recipientInput, cmd = m.recipientInput.Update(msg) + case 1: + m.subjectInput, cmd = m.subjectInput.Update(msg) + case 2: + m.bodyArea, cmd = m.bodyArea.Update(msg) + } + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) +} + +func (m composerModel) View() string { + if m.err != nil { + return InfoStyle.Render(fmt.Sprintf("\n Error: %v \n\n Press any key to exit.", m.err)) + } + if m.sent { + return SuccessStyle.Render("\n ✓ Email sent successfully! \n\n") + } + if m.sending { + return InfoStyle.Render("\n Sending email... \n\n") + } + var s strings.Builder + s.WriteString("\n") + s.WriteString(m.recipientInput.View() + "\n") + s.WriteString(m.subjectInput.View() + "\n") + s.WriteString(m.bodyArea.View()) + help := fmt.Sprintf("\n\n %s | %s | %s \n", "Tab: Next Field", "Esc: Quit", "Ctrl+S: Send") + s.WriteString(HelpStyle.Render(help)) + return DialogBoxStyle.Render(s.String()) +} + +// RunComposer starts the email composer UI. +func RunComposer(cfg *config.Config) error { + p := tea.NewProgram(initialComposerModel(cfg)) + _, err := p.Run() + return err +} \ No newline at end of file diff --git a/tui/login.go b/tui/login.go new file mode 100644 index 0000000000000000000000000000000000000000..786e5d00f1d27334956713d735dc38d223bdc63d --- /dev/null +++ b/tui/login.go @@ -0,0 +1,142 @@ +package tui + +import ( + "strings" + + "github.com/andrinoff/email-cli/config" + "github.com/charmbracelet/bubbles/cursor" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type loginModel struct { + focusIndex int + inputs []textinput.Model + cursorMode cursor.Mode + config *config.Config + err error +} + +func initialLoginModel() loginModel { + m := loginModel{ + inputs: make([]textinput.Model, 3), + } + + var t textinput.Model + for i := range m.inputs { + t = textinput.New() + t.Cursor.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + t.CharLimit = 32 + + switch i { + case 0: + t.Placeholder = "gmail or icloud" + t.Focus() + t.Prompt = "> " + t.CharLimit = 10 + case 1: + t.Placeholder = "your@email.com" + t.Prompt = " " + t.CharLimit = 64 + case 2: + t.Placeholder = "App-specific password" + t.Prompt = " " + t.EchoMode = textinput.EchoPassword + t.EchoCharacter = '•' + t.CharLimit = 64 + } + m.inputs[i] = t + } + + return m +} + +func (m loginModel) Init() tea.Cmd { return textinput.Blink } + +func (m loginModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "esc": + return m, tea.Quit + case "tab", "shift+tab", "enter", "up", "down": + s := msg.String() + if s == "enter" && m.focusIndex == len(m.inputs) { + m.config = &config.Config{ + ServiceProvider: m.inputs[0].Value(), + Email: m.inputs[1].Value(), + Password: m.inputs[2].Value(), + } + if err := config.SaveConfig(m.config); err != nil { + m.err = err + return m, nil + } + return m, tea.Quit + } + if s == "up" || s == "shift+tab" { + m.focusIndex-- + } else { + m.focusIndex++ + } + if m.focusIndex > len(m.inputs) { + m.focusIndex = 0 + } else if m.focusIndex < 0 { + m.focusIndex = len(m.inputs) + } + cmds := make([]tea.Cmd, len(m.inputs)) + for i := 0; i < len(m.inputs); i++ { + if i == m.focusIndex { + cmds[i] = m.inputs[i].Focus() + m.inputs[i].Prompt = "> " + continue + } + m.inputs[i].Blur() + m.inputs[i].Prompt = " " + } + return m, tea.Batch(cmds...) + } + } + cmd := m.updateInputs(msg) + return m, cmd +} + +func (m *loginModel) updateInputs(msg tea.Msg) tea.Cmd { + cmds := make([]tea.Cmd, len(m.inputs)) + for i := range m.inputs { + m.inputs[i], cmds[i] = m.inputs[i].Update(msg) + } + return tea.Batch(cmds...) +} + +func (m loginModel) View() string { + var b strings.Builder + b.WriteString(InfoStyle.Render(" Welcome to email-cli! Please configure your email account. ") + "\n\n") + for i := range m.inputs { + b.WriteString(m.inputs[i].View()) + if i < len(m.inputs)-1 { + b.WriteRune('\n') + } + } + button := "\n\n" + "[ Submit ]" + if m.focusIndex == len(m.inputs) { + button = "\n\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Render("[ Submit ]") + } + b.WriteString(button) + if m.err != nil { + b.WriteString("\n\nError: " + m.err.Error()) + } + b.WriteString(HelpStyle.Render("\n\n(tab to navigate, enter to submit, esc to quit)")) + return DialogBoxStyle.Render(b.String()) +} + +// RunLogin starts the login UI and returns the created config. +func RunLogin() (*config.Config, error) { + p := tea.NewProgram(initialLoginModel()) + m, err := p.Run() + if err != nil { + return nil, err + } + finalModel := m.(loginModel) + return finalModel.config, nil +} \ No newline at end of file diff --git a/tui/styles.go b/tui/styles.go new file mode 100644 index 0000000000000000000000000000000000000000..30b89078231c016347fa1c284bf61863611f546a --- /dev/null +++ b/tui/styles.go @@ -0,0 +1,18 @@ +package tui + +import "github.com/charmbracelet/lipgloss" + +var ( + DialogBoxStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#874BFD")). + Padding(1, 0). + BorderTop(true). + BorderLeft(true). + BorderRight(true). + BorderBottom(true) + + HelpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + SuccessStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true) + InfoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true) +) \ No newline at end of file