From 14351d687c077b6c2b79a13b89b986961b7e4732 Mon Sep 17 00:00:00 2001 From: drew Date: Tue, 29 Jul 2025 09:43:11 +0400 Subject: [PATCH] fix: add hyperlink and images support to markdown (#10) --- go.mod | 1 + main.go | 68 ++++++++++++++++++++++++++++----------------- sender/sender.go | 72 ++++++++++++++++++++++++++++++++++++------------ 3 files changed, 99 insertions(+), 42 deletions(-) diff --git a/go.mod b/go.mod index 44011ab9e16e466491552c19d5d4d412ddbf8144..4c7e666720ecb8d3e740a847ebb95cd5b59666ea 100644 --- a/go.mod +++ b/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 diff --git a/main.go b/main.go index 182056a80e0ad60cdaf8c935ae2c3f87638b8a83..d361a8cdbae2095997621d2c529961d148619fc6 100644 --- a/main.go +++ b/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) diff --git a/sender/sender.go b/sender/sender.go index 37521221a89b30bb4fe55cf971f29b2d70c3dbf0..7cf4537c547f763989bb87e456a90bf591dbfc2e 100644 --- a/sender/sender.go +++ b/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()) } \ No newline at end of file