diff --git a/smtp/sender.go b/smtp/sender.go index baeb7d5..878b234 100644 --- a/smtp/sender.go +++ b/smtp/sender.go @@ -1,9 +1,12 @@ package smtp import ( + "bytes" "context" "fmt" + stdhtml "html" "log/slog" + "strings" gomail "github.com/wneessen/go-mail" @@ -11,6 +14,51 @@ import ( "github.com/5000K/5000mails/domain" ) +var blockElements = []string{ + "address", "article", "aside", "blockquote", "br", "dd", "details", + "dialog", "div", "dl", "dt", "fieldset", "figcaption", "figure", + "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6", "header", + "hgroup", "hr", "li", "main", "nav", "ol", "p", "pre", "section", + "summary", "table", "td", "th", "tr", "ul", +} + +func htmlToPlainText(src []byte) []byte { + var buf bytes.Buffer + i := 0 + for i < len(src) { + if src[i] != '<' { + buf.WriteByte(src[i]) + i++ + continue + } + end := bytes.IndexByte(src[i:], '>') + if end == -1 { + buf.Write(src[i:]) + break + } + inner := src[i+1 : i+end] + if len(inner) > 0 && inner[0] == '/' { + inner = inner[1:] + } + tagName := strings.ToLower(string(inner)) + if sp := strings.IndexByte(tagName, ' '); sp != -1 { + tagName = tagName[:sp] + } + for _, bt := range blockElements { + if tagName == bt { + buf.WriteByte('\n') + break + } + } + i += end + 1 + } + text := stdhtml.UnescapeString(buf.String()) + for strings.Contains(text, "\n\n\n") { + text = strings.ReplaceAll(text, "\n\n\n", "\n\n") + } + return []byte(strings.TrimSpace(text)) +} + type Sender struct { client *gomail.Client senderEmail string @@ -61,6 +109,7 @@ func (s *Sender) SendMail(ctx context.Context, metadata domain.MailMetadata, bod msg.Subject(metadata.Subject) msg.SetBodyString(gomail.TypeTextHTML, body) + msg.AddAlternativeString(gomail.TypeTextPlain, string(htmlToPlainText([]byte(body)))) if err := s.client.DialAndSendWithContext(ctx, msg); err != nil { s.logger.ErrorContext(ctx, "failed to send mail", diff --git a/smtp/sender_test.go b/smtp/sender_test.go new file mode 100644 index 0000000..3c33f3c --- /dev/null +++ b/smtp/sender_test.go @@ -0,0 +1,53 @@ +package smtp + +import ( + "testing" +) + +func TestHtmlToPlainText(t *testing.T) { + tests := []struct { + name string + src string + want string + }{ + { + name: "strips tags and keeps text", + src: "
Hello, world!
", + want: "Hello, world!", + }, + { + name: "inserts newline at block boundaries", + src: "First
Second
", + want: "First\n\nSecond", + }, + { + name: "unescapes html entities", + src: "a & b <3
", + want: "a & b <3", + }, + { + name: "collapses excessive blank lines", + src: "Body
", + want: "Title\n\nBody", + }, + { + name: "handles unclosed tag at end", + src: "Truncated