diff --git a/go.mod b/go.mod index 4bf4b68f9084f70c10bd4b09eebef384466dee21..44011ab9e16e466491552c19d5d4d412ddbf8144 100644 --- a/go.mod +++ b/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 diff --git a/main.go b/main.go index 55775a201c8672fabdaf47e27d861f2c875d4f24..182056a80e0ad60cdaf8c935ae2c3f87638b8a83 100644 --- a/main.go +++ b/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} diff --git a/sender/sender.go b/sender/sender.go index b8e935128694d8046413d408fd3b70a7ce61d230..37521221a89b30bb4fe55cf971f29b2d70c3dbf0 100644 --- a/sender/sender.go +++ b/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()) } \ No newline at end of file diff --git a/tui/composer.go b/tui/composer.go index b50d04d60b00ff855414b49a2219441e7cddce33..0a291c0622ee4820538865dcf18aa3783103f32f 100644 --- a/tui/composer.go +++ b/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"), ) } \ No newline at end of file