feat: new UI for login + better file structure

drew created

Change summary

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

Detailed changes

cmd/gmail/send.go 🔗

@@ -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!")
-}

cmd/icloud/send.go 🔗

@@ -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!")
-}

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

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

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

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

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

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