fix: add hyperlink and images support to markdown (#10)

drew created

Change summary

go.mod           |  1 
main.go          | 68 +++++++++++++++++++++++++++++-----------------
sender/sender.go | 72 ++++++++++++++++++++++++++++++++++++++-----------
3 files changed, 99 insertions(+), 42 deletions(-)

Detailed changes

go.mod 🔗

@@ -20,6 +20,7 @@ require (
 	github.com/emersion/go-message v0.18.2 // indirect
 	github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
 	github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
+	github.com/google/uuid v1.6.0 // indirect
 	github.com/gorilla/css v1.0.1 // indirect
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
 	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect

main.go 🔗

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

sender/sender.go 🔗

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