@@ -2,9 +2,13 @@ package main
import (
"bytes"
+ "encoding/base64"
"fmt"
"log"
"os"
+ "path/filepath"
+ "regexp"
+ "strings"
"time"
"github.com/andrinoff/email-cli/config"
@@ -12,11 +16,12 @@ import (
"github.com/andrinoff/email-cli/sender"
"github.com/andrinoff/email-cli/tui"
tea "github.com/charmbracelet/bubbletea"
+ "github.com/google/uuid"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/renderer/html"
)
-// mainModel now holds the state for the entire application.
+// mainModel holds the state for the entire application.
type mainModel struct {
current tea.Model
config *config.Config
@@ -27,9 +32,9 @@ type mainModel struct {
err error
}
-// newInitialModel now returns a pointer, which is crucial for state management.
+// newInitialModel returns a pointer to the initial model.
func newInitialModel(cfg *config.Config) *mainModel {
- // If config is nil, it means we're starting from the login screen.
+ // If config is nil, start with the login screen.
if cfg == nil {
return &mainModel{
current: tui.NewLogin(),
@@ -53,7 +58,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
- // Pass the window size message to the current view
+ // Pass the window size message to the current view.
m.current, cmd = m.current.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
@@ -62,21 +67,20 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.String() == "ctrl+c" {
return m, tea.Quit
}
- // Allow ESC to go back
+ // Allow ESC to navigate back.
if msg.String() == "esc" {
- // Check the type of the current view to decide where to go.
switch m.current.(type) {
case *tui.EmailView:
- m.current = m.inbox // Go back to the cached inbox
+ m.current = m.inbox // Go back to the cached inbox.
return m, nil
case *tui.Inbox, *tui.Composer, *tui.Login:
- m.current = tui.NewChoice() // Go back to the main menu
+ m.current = tui.NewChoice() // Go back to the main menu.
return m, m.current.Init()
}
}
- // --- Custom Messages for switching views ---
- case tui.Credentials: // This is a struct, not a pointer
+ // --- Custom Messages for Switching Views ---
+ case tui.Credentials:
cfg := &config.Config{
ServiceProvider: msg.Provider,
Name: msg.Name,
@@ -84,7 +88,6 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
Password: msg.Password,
}
if err := config.SaveConfig(cfg); err != nil {
- // TODO: Show an error message to the user
log.Printf("could not save config: %v", err)
return m, tea.Quit
}
@@ -100,12 +103,11 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.emails = msg.Emails
m.inbox = tui.NewInbox(m.emails)
m.current = m.inbox
- // Manually set the size of the new view
+ // Manually set the size of the new view.
m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
cmds = append(cmds, m.current.Init())
case tui.GoToSendMsg:
- // NewComposer now returns *Composer, so we assign it directly.
m.current = tui.NewComposer(m.config.Email)
m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
cmds = append(cmds, m.current.Init())
@@ -129,7 +131,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, m.current.Init())
}
- // Pass all other messages to the current view
+ // Pass all other messages to the current view.
m.current, cmd = m.current.Update(msg)
cmds = append(cmds, cmd)
@@ -145,36 +147,53 @@ func markdownToHTML(md []byte) []byte {
var buf bytes.Buffer
p := goldmark.New(
goldmark.WithRendererOptions(
- html.WithUnsafe(), // Allow raw HTML, which is useful in emails
+ html.WithUnsafe(), // Allow raw HTML in email.
),
)
if err := p.Convert(md, &buf); err != nil {
- // As a fallback, just return the original markdown.
- return md
+ return md // Fallback to original markdown.
}
return buf.Bytes()
}
-// sendEmail is a command that sends an email in the background.
+// sendEmail finds local image paths, embeds them, and sends the email.
func sendEmail(cfg *config.Config, msg tui.SendEmailMsg) tea.Cmd {
return func() tea.Msg {
recipients := []string{msg.To}
+ body := msg.Body
+ images := make(map[string][]byte)
+
+ // Find all markdown image tags.
+ re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
+ matches := re.FindAllStringSubmatch(body, -1)
+
+ for _, match := range matches {
+ imgPath := match[1]
+ imgData, err := os.ReadFile(imgPath) // Use os.ReadFile.
+ if err != nil {
+ log.Printf("Could not read image file %s: %v", imgPath, err)
+ continue
+ }
- // Convert markdown body to HTML
- htmlBody := markdownToHTML([]byte(msg.Body))
+ // Create a unique CID that includes the file extension.
+ cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "email-cli")
+ images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
+ body = strings.Replace(body, imgPath, "cid:"+cid, 1)
+ }
- // The original markdown body will serve as our plain text fallback.
- err := sender.SendEmail(cfg, recipients, msg.Subject, msg.Body, string(htmlBody))
+ htmlBody := markdownToHTML([]byte(body))
+ err := sender.SendEmail(cfg, recipients, msg.Subject, msg.Body, string(htmlBody), images)
if err != nil {
- log.Printf("Failed to send email: %v", err) // Log error
+ log.Printf("Failed to send email: %v", err)
return tui.EmailResultMsg{Err: err}
}
- time.Sleep(1 * time.Second) // Give user time to see the "Sending" message
+ time.Sleep(1 * time.Second)
return tui.EmailResultMsg{}
}
}
+// fetchEmails retrieves emails in the background.
func fetchEmails(cfg *config.Config) tea.Cmd {
return func() tea.Msg {
emails, err := fetcher.FetchEmails(cfg)
@@ -189,7 +208,6 @@ func main() {
cfg, err := config.LoadConfig()
var initialModel *mainModel
if err != nil {
- // If the config doesn't exist, we can guide the user to create one.
initialModel = newInitialModel(nil)
} else {
initialModel = newInitialModel(cfg)
@@ -3,10 +3,14 @@ package sender
import (
"bytes"
"crypto/rand"
+ "encoding/base64"
"fmt"
+ "mime"
"mime/multipart"
"net/smtp"
"net/textproto"
+ "path/filepath"
+ "strings"
"time"
"github.com/andrinoff/email-cli/config"
@@ -22,8 +26,8 @@ func generateMessageID(from string) string {
return fmt.Sprintf("<%x@%s>", buf, from)
}
-// SendEmail now constructs a multipart message with both plain text and HTML parts.
-func SendEmail(cfg *config.Config, to []string, subject, plainBody, htmlBody string) error {
+// SendEmail constructs a multipart message with plain text, HTML, and embedded images.
+func SendEmail(cfg *config.Config, to []string, subject, plainBody, htmlBody string, images map[string][]byte) error {
var smtpServer string
var smtpPort int
@@ -45,49 +49,83 @@ func SendEmail(cfg *config.Config, to []string, subject, plainBody, htmlBody str
fromHeader = fmt.Sprintf("%s <%s>", cfg.Name, cfg.Email)
}
- // Create a new multipart message.
+ // Main message buffer
var msg bytes.Buffer
- mw := multipart.NewWriter(&msg)
+ mainWriter := multipart.NewWriter(&msg)
- // Set top-level headers.
+ // Set top-level headers for multipart/related
headers := map[string]string{
"From": fromHeader,
"To": to[0],
"Subject": subject,
"Date": time.Now().Format(time.RFC1123Z),
"Message-ID": generateMessageID(cfg.Email),
- "Content-Type": "multipart/alternative; boundary=" + mw.Boundary(),
+ "Content-Type": "multipart/related; boundary=" + mainWriter.Boundary(),
}
for k, v := range headers {
fmt.Fprintf(&msg, "%s: %s\r\n", k, v)
}
fmt.Fprintf(&msg, "\r\n") // End of headers
- // Create plain text part.
+ // Create the multipart/alternative part as a nested part
+ altHeader := textproto.MIMEHeader{}
+ altBoundary := "alt-" + mainWriter.Boundary()
+ altHeader.Set("Content-Type", "multipart/alternative; boundary="+altBoundary)
+ altPartWriter, err := mainWriter.CreatePart(altHeader)
+ if err != nil {
+ return err
+ }
+
+ altWriter := multipart.NewWriter(altPartWriter)
+ altWriter.SetBoundary(altBoundary)
+
+ // Create plain text part inside multipart/alternative
textHeader := textproto.MIMEHeader{}
textHeader.Set("Content-Type", "text/plain; charset=UTF-8")
- textHeader.Set("Content-Transfer-Encoding", "quoted-printable")
- part, err := mw.CreatePart(textHeader)
+ textPart, err := altWriter.CreatePart(textHeader)
if err != nil {
return err
}
- fmt.Fprint(part, plainBody)
+ fmt.Fprint(textPart, plainBody)
- // Create HTML part.
+ // Create HTML part inside multipart/alternative
htmlHeader := textproto.MIMEHeader{}
htmlHeader.Set("Content-Type", "text/html; charset=UTF-8")
- htmlHeader.Set("Content-Transfer-Encoding", "quoted-printable")
- part, err = mw.CreatePart(htmlHeader)
+ htmlPart, err := altWriter.CreatePart(htmlHeader)
if err != nil {
return err
}
- fmt.Fprint(part, htmlBody)
+ fmt.Fprint(htmlPart, htmlBody)
- // Close the multipart writer to write the final boundary.
- if err := mw.Close(); err != nil {
- return err
+ altWriter.Close()
+
+ // Attach images to the main multipart/related part
+ for cid, data := range images {
+ ext := filepath.Ext(strings.Split(cid, "@")[0]) // Extract extension from CID
+ mimeType := mime.TypeByExtension(ext)
+ if mimeType == "" {
+ mimeType = "application/octet-stream"
+ }
+
+ imgHeader := textproto.MIMEHeader{}
+ imgHeader.Set("Content-Type", mimeType)
+ imgHeader.Set("Content-Transfer-Encoding", "base64")
+ imgHeader.Set("Content-ID", "<"+cid+">")
+ imgHeader.Set("Content-Disposition", "inline; filename=\""+cid+"\"")
+
+ imgPart, err := mainWriter.CreatePart(imgHeader)
+ if err != nil {
+ return err
+ }
+ decodedData, err := base64.StdEncoding.DecodeString(string(data))
+ if err != nil {
+ return err
+ }
+ imgPart.Write(decodedData)
}
+ mainWriter.Close()
+
addr := fmt.Sprintf("%s:%d", smtpServer, smtpPort)
return smtp.SendMail(addr, auth, cfg.Email, to, msg.Bytes())
}