feat: markdown (not full yet) (#10)

drew created

Change summary

go.mod           |  1 
main.go          | 28 ++++++++++++++++++++++-
sender/sender.go | 59 +++++++++++++++++++++++++++++++++----------------
tui/composer.go  |  4 +-
4 files changed, 69 insertions(+), 23 deletions(-)

Detailed changes

go.mod 🔗

@@ -35,6 +35,7 @@ require (
 	github.com/spf13/cobra v1.9.1 // indirect
 	github.com/spf13/pflag v1.0.7 // indirect
 	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+	github.com/yuin/goldmark v1.7.13 // indirect
 	golang.org/x/net v0.39.0 // indirect
 	golang.org/x/sync v0.16.0 // indirect
 	golang.org/x/sys v0.34.0 // indirect

main.go 🔗

@@ -1,6 +1,7 @@
 package main
 
 import (
+	"bytes"
 	"fmt"
 	"log"
 	"os"
@@ -11,6 +12,8 @@ import (
 	"github.com/andrinoff/email-cli/sender"
 	"github.com/andrinoff/email-cli/tui"
 	tea "github.com/charmbracelet/bubbletea"
+	"github.com/yuin/goldmark"
+	"github.com/yuin/goldmark/renderer/html"
 )
 
 // mainModel now holds the state for the entire application.
@@ -106,7 +109,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		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())
-	
+
 	case tui.GoToSettingsMsg:
 		m.current = tui.NewLogin()
 		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
@@ -137,11 +140,32 @@ func (m *mainModel) View() string {
 	return m.current.View()
 }
 
+// markdownToHTML converts a Markdown string to an HTML string.
+func markdownToHTML(md []byte) []byte {
+	var buf bytes.Buffer
+	p := goldmark.New(
+		goldmark.WithRendererOptions(
+			html.WithUnsafe(), // Allow raw HTML, which is useful in emails
+		),
+	)
+	if err := p.Convert(md, &buf); err != nil {
+		// As a fallback, just return the original markdown.
+		return md
+	}
+	return buf.Bytes()
+}
+
 // sendEmail is a command that sends an email in the background.
 func sendEmail(cfg *config.Config, msg tui.SendEmailMsg) tea.Cmd {
 	return func() tea.Msg {
 		recipients := []string{msg.To}
-		err := sender.SendEmail(cfg, recipients, msg.Subject, msg.Body)
+
+		// Convert markdown body to HTML
+		htmlBody := markdownToHTML([]byte(msg.Body))
+
+		// The original markdown body will serve as our plain text fallback.
+		err := sender.SendEmail(cfg, recipients, msg.Subject, msg.Body, string(htmlBody))
+
 		if err != nil {
 			log.Printf("Failed to send email: %v", err) // Log error
 			return tui.EmailResultMsg{Err: err}

sender/sender.go 🔗

@@ -1,32 +1,32 @@
 package sender
 
 import (
+	"bytes"
 	"crypto/rand"
 	"fmt"
+	"mime/multipart"
 	"net/smtp"
+	"net/textproto"
 	"time"
 
 	"github.com/andrinoff/email-cli/config"
 )
 
 // generateMessageID creates a unique Message-ID header.
-// This is a crucial header for deliverability, as its absence is a spam indicator.
 func generateMessageID(from string) string {
-	// Generate a random part to ensure uniqueness
 	buf := make([]byte, 16)
 	_, err := rand.Read(buf)
 	if err != nil {
-		// Fallback to a less random but still unique value if crypto/rand fails
 		return fmt.Sprintf("<%d.%s>", time.Now().UnixNano(), from)
 	}
 	return fmt.Sprintf("<%x@%s>", buf, from)
 }
 
-func SendEmail(cfg *config.Config, to []string, subject, body string) error {
+// 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 {
 	var smtpServer string
 	var smtpPort int
 
-	// Determine the SMTP server based on the service provider in the config.
 	switch cfg.ServiceProvider {
 	case "gmail":
 		smtpServer = "smtp.gmail.com"
@@ -38,35 +38,56 @@ func SendEmail(cfg *config.Config, to []string, subject, body string) error {
 		return fmt.Errorf("unsupported or missing service_provider in config.json: %s", cfg.ServiceProvider)
 	}
 
-	// Set up authentication information.
 	auth := smtp.PlainAuth("", cfg.Email, cfg.Password, smtpServer)
 
-	// Format the From header to include the sender's name.
 	fromHeader := cfg.Email
 	if cfg.Name != "" {
 		fromHeader = fmt.Sprintf("%s <%s>", cfg.Name, cfg.Email)
 	}
 
-	// Construct the full email message with proper headers.
+	// Create a new multipart message.
+	var msg bytes.Buffer
+	mw := multipart.NewWriter(&msg)
+
+	// Set top-level headers.
 	headers := map[string]string{
 		"From":         fromHeader,
-		"To":           to[0], // Assuming one recipient for the header display
+		"To":           to[0],
 		"Subject":      subject,
 		"Date":         time.Now().Format(time.RFC1123Z),
 		"Message-ID":   generateMessageID(cfg.Email),
-		"Content-Type": "text/plain; charset=UTF-8", // Explicitly set content type
+		"Content-Type": "multipart/alternative; boundary=" + mw.Boundary(),
 	}
-
-	var msg string
 	for k, v := range headers {
-		msg += fmt.Sprintf("%s: %s\r\n", k, v)
+		fmt.Fprintf(&msg, "%s: %s\r\n", k, v)
 	}
-	msg += "\r\n" + body
+	fmt.Fprintf(&msg, "\r\n") // End of headers
 
-	// SMTP server address.
-	addr := fmt.Sprintf("%s:%d", smtpServer, smtpPort)
+	// Create plain text part.
+	textHeader := textproto.MIMEHeader{}
+	textHeader.Set("Content-Type", "text/plain; charset=UTF-8")
+	textHeader.Set("Content-Transfer-Encoding", "quoted-printable")
+	part, err := mw.CreatePart(textHeader)
+	if err != nil {
+		return err
+	}
+	fmt.Fprint(part, plainBody)
 
-	// Send the email.
-	err := smtp.SendMail(addr, auth, cfg.Email, to, []byte(msg))
-	return err
+	// Create HTML part.
+	htmlHeader := textproto.MIMEHeader{}
+	htmlHeader.Set("Content-Type", "text/html; charset=UTF-8")
+	htmlHeader.Set("Content-Transfer-Encoding", "quoted-printable")
+	part, err = mw.CreatePart(htmlHeader)
+	if err != nil {
+		return err
+	}
+	fmt.Fprint(part, htmlBody)
+
+	// Close the multipart writer to write the final boundary.
+	if err := mw.Close(); err != nil {
+		return err
+	}
+
+	addr := fmt.Sprintf("%s:%d", smtpServer, smtpPort)
+	return smtp.SendMail(addr, auth, cfg.Email, to, msg.Bytes())
 }

tui/composer.go 🔗

@@ -46,7 +46,7 @@ func NewComposer(from string) *Composer {
 
 	m.bodyInput = textarea.New()
 	m.bodyInput.Cursor.Style = cursorStyle
-	m.bodyInput.Placeholder = "Body..."
+	m.bodyInput.Placeholder = "Body (Markdown supported)..."
 	m.bodyInput.Prompt = "> "
 	m.bodyInput.SetHeight(10)
 
@@ -151,6 +151,6 @@ func (m *Composer) View() string {
 		m.subjectInput.View(),
 		m.bodyInput.View(),
 		*button,
-		helpStyle.Render("tab: next field • esc: back to menu • enter: send"),
+		helpStyle.Render("Markdown enabled! • tab: next field • esc: back to menu"),
 	)
 }