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