diff --git a/internal/mailer/html2text.go b/internal/mailer/html2text.go deleted file mode 100644 index 0bf07a1..0000000 --- a/internal/mailer/html2text.go +++ /dev/null @@ -1,103 +0,0 @@ -package mailer - -import ( - "regexp" - "strings" - - "golang.org/x/net/html" -) - -var whitespaceRegex = regexp.MustCompile(`\s+`) - -// Very rudimentary auto HTML to Text mail body converter. -// -// Caveats: -// - This method doesn't check for correctness of the HTML document. -// - Links will be converted to "[text](url)" format. -// - List items (
  • ) are prefixed with "- ". -// - Indentation is stripped (both tabs and spaces). -// - Trailing spaces are preserved. -// - Multiple consequence newlines are collapsed as one unless multiple
    tags are used. -func html2Text(htmlDocument string) (string, error) { - doc, err := html.Parse(strings.NewReader(htmlDocument)) - if err != nil { - return "", err - } - - var builder strings.Builder - var canAddNewLine bool - - // see https://pkg.go.dev/golang.org/x/net/html#Parse - var f func(*html.Node, *strings.Builder) - f = func(n *html.Node, activeBuilder *strings.Builder) { - isLink := n.Type == html.ElementNode && n.Data == "a" - - if isLink { - var linkBuilder strings.Builder - activeBuilder = &linkBuilder - } else if activeBuilder == nil { - activeBuilder = &builder - } - - switch n.Type { - case html.TextNode: - txt := whitespaceRegex.ReplaceAllString(n.Data, " ") - - // the prev node has new line so it is safe to trim the indentation - if !canAddNewLine { - txt = strings.TrimLeft(txt, " ") - } - - if txt != "" { - activeBuilder.WriteString(txt) - canAddNewLine = true - } - case html.ElementNode: - if n.Data == "br" { - // always write new lines when
    tag is used - activeBuilder.WriteString("\r\n") - canAddNewLine = false - } - // prefix list items with dash - if n.Data == "li" { - activeBuilder.WriteString("- ") - } - } - - for c := n.FirstChild; c != nil; c = c.NextSibling { - if c.Type != html.ElementNode { - f(c, activeBuilder) - } - } - - // format links as [label](href) - if isLink { - linkTxt := strings.TrimSpace(activeBuilder.String()) - if linkTxt == "" { - linkTxt = "LINK" - } - - builder.WriteString("[") - builder.WriteString(linkTxt) - builder.WriteString("]") - - // link href attr extraction - for _, a := range n.Attr { - if a.Key == "href" { - if a.Val != "" { - builder.WriteString("(") - builder.WriteString(a.Val) - builder.WriteString(")") - } - break - } - } - - activeBuilder.Reset() - } - } - - f(doc, &builder) - - return strings.TrimSpace(builder.String()), nil -} diff --git a/internal/mailer/mailer.go b/internal/mailer/mailer.go deleted file mode 100644 index b64da89..0000000 --- a/internal/mailer/mailer.go +++ /dev/null @@ -1,43 +0,0 @@ -package mailer - -import ( - "io" - "net/mail" -) - -// Message defines a generic email message struct. -type Message struct { - From mail.Address `json:"from"` - To []mail.Address `json:"to"` - Bcc []mail.Address `json:"bcc"` - Cc []mail.Address `json:"cc"` - Subject string `json:"subject"` - HTML string `json:"html"` - Text string `json:"text"` - Headers map[string]string `json:"headers"` - Attachments map[string]io.Reader `json:"attachments"` -} - -// Mailer defines a base mail client interface. -type Mailer interface { - // Send sends an email with the provided Message. - Send(message *Message) error -} - -// addressesToStrings converts the provided address to a list of serialized RFC 5322 strings. -// -// To export only the email part of mail.Address, you can set withName to false. -func addressesToStrings(addresses []mail.Address, withName bool) []string { - result := make([]string, len(addresses)) - - for i, addr := range addresses { - if withName && addr.Name != "" { - result[i] = addr.String() - } else { - // keep only the email part to avoid wrapping in angle-brackets - result[i] = addr.Address - } - } - - return result -} diff --git a/internal/mailer/sendmail.go b/internal/mailer/sendmail.go deleted file mode 100644 index 6826a03..0000000 --- a/internal/mailer/sendmail.go +++ /dev/null @@ -1,78 +0,0 @@ -package mailer - -import ( - "bytes" - "errors" - "mime" - "net/http" - "os/exec" - "strings" -) - -var _ Mailer = (*Sendmail)(nil) - -// Sendmail implements [mailer.Mailer] interface and defines a mail -// client that sends emails via the "sendmail" *nix command. -// -// This client is usually recommended only for development and testing. -type Sendmail struct { -} - -// Send implements `mailer.Mailer` interface. -func (c *Sendmail) Send(m *Message) error { - toAddresses := addressesToStrings(m.To, false) - - headers := make(http.Header) - headers.Set("Subject", mime.QEncoding.Encode("utf-8", m.Subject)) - headers.Set("From", m.From.String()) - headers.Set("Content-Type", "text/html; charset=UTF-8") - headers.Set("To", strings.Join(toAddresses, ",")) - - cmdPath, err := findSendmailPath() - if err != nil { - return err - } - - var buffer bytes.Buffer - - // write - // --- - if err := headers.Write(&buffer); err != nil { - return err - } - if _, err := buffer.Write([]byte("\r\n")); err != nil { - return err - } - if m.HTML != "" { - if _, err := buffer.Write([]byte(m.HTML)); err != nil { - return err - } - } else { - if _, err := buffer.Write([]byte(m.Text)); err != nil { - return err - } - } - // --- - - sendmail := exec.Command(cmdPath, strings.Join(toAddresses, ",")) - sendmail.Stdin = &buffer - - return sendmail.Run() -} - -func findSendmailPath() (string, error) { - options := []string{ - "/usr/sbin/sendmail", - "/usr/bin/sendmail", - "sendmail", - } - - for _, option := range options { - path, err := exec.LookPath(option) - if err == nil { - return path, err - } - } - - return "", errors.New("failed to locate a sendmail executable path") -} diff --git a/internal/mailer/smtp.go b/internal/mailer/smtp.go deleted file mode 100644 index 58bbade..0000000 --- a/internal/mailer/smtp.go +++ /dev/null @@ -1,196 +0,0 @@ -package mailer - -import ( - "errors" - "fmt" - "net/smtp" - "strings" - - "github.com/domodwyer/mailyak/v3" - "github.com/google/uuid" -) - -var _ Mailer = (*SmtpClient)(nil) - -const ( - SmtpAuthPlain = "PLAIN" - SmtpAuthLogin = "LOGIN" -) - -// Deprecated: Use directly the SmtpClient struct literal. -// -// NewSmtpClient creates new SmtpClient with the provided configuration. -func NewSmtpClient( - host string, - port int, - username string, - password string, - tls bool, -) *SmtpClient { - return &SmtpClient{ - Host: host, - Port: port, - Username: username, - Password: password, - Tls: tls, - } -} - -// SmtpClient defines a SMTP mail client structure that implements -// `mailer.Mailer` interface. -type SmtpClient struct { - Host string - Port int - Username string - Password string - Tls bool - - // SMTP auth method to use - // (if not explicitly set, defaults to "PLAIN") - AuthMethod string - - // LocalName is optional domain name used for the EHLO/HELO exchange - // (if not explicitly set, defaults to "localhost"). - // - // This is required only by some SMTP servers, such as Gmail SMTP-relay. - LocalName string -} - -// Send implements `mailer.Mailer` interface. -func (c *SmtpClient) Send(m *Message) error { - var smtpAuth smtp.Auth - if c.Username != "" || c.Password != "" { - switch c.AuthMethod { - case SmtpAuthLogin: - smtpAuth = &smtpLoginAuth{c.Username, c.Password} - default: - smtpAuth = smtp.PlainAuth("", c.Username, c.Password, c.Host) - } - } - - // create mail instance - var yak *mailyak.MailYak - if c.Tls { - var tlsErr error - yak, tlsErr = mailyak.NewWithTLS(fmt.Sprintf("%s:%d", c.Host, c.Port), smtpAuth, nil) - if tlsErr != nil { - return tlsErr - } - } else { - yak = mailyak.New(fmt.Sprintf("%s:%d", c.Host, c.Port), smtpAuth) - } - - if c.LocalName != "" { - yak.LocalName(c.LocalName) - } - - if m.From.Name != "" { - yak.FromName(m.From.Name) - } - yak.From(m.From.Address) - yak.Subject(m.Subject) - yak.HTML().Set(m.HTML) - - if m.Text == "" { - // try to generate a plain text version of the HTML - if plain, err := html2Text(m.HTML); err == nil { - yak.Plain().Set(plain) - } - } else { - yak.Plain().Set(m.Text) - } - - if len(m.To) > 0 { - yak.To(addressesToStrings(m.To, true)...) - } - - if len(m.Bcc) > 0 { - yak.Bcc(addressesToStrings(m.Bcc, true)...) - } - - if len(m.Cc) > 0 { - yak.Cc(addressesToStrings(m.Cc, true)...) - } - - // add attachements (if any) - for name, data := range m.Attachments { - yak.Attach(name, data) - } - - // add custom headers (if any) - var hasMessageId bool - for k, v := range m.Headers { - if strings.EqualFold(k, "Message-ID") { - hasMessageId = true - } - yak.AddHeader(k, v) - } - if !hasMessageId { - // add a default message id if missing - fromParts := strings.Split(m.From.Address, "@") - if len(fromParts) == 2 { - yak.AddHeader("Message-ID", fmt.Sprintf("<%s@%s>", - uuid.New().String(), - fromParts[1], - )) - } - } - - return yak.Send() -} - -// ------------------------------------------------------------------- -// AUTH LOGIN -// ------------------------------------------------------------------- - -var _ smtp.Auth = (*smtpLoginAuth)(nil) - -// smtpLoginAuth defines an AUTH that implements the LOGIN authentication mechanism. -// -// AUTH LOGIN is obsolete[1] but some mail services like outlook requires it [2]. -// -// NB! -// It will only send the credentials if the connection is using TLS or is connected to localhost. -// Otherwise authentication will fail with an error, without sending the credentials. -// -// [1]: https://github.com/golang/go/issues/40817 -// [2]: https://support.microsoft.com/en-us/office/outlook-com-no-longer-supports-auth-plain-authentication-07f7d5e9-1697-465f-84d2-4513d4ff0145?ui=en-us&rs=en-us&ad=us -type smtpLoginAuth struct { - username, password string -} - -// Start initializes an authentication with the server. -// -// It is part of the [smtp.Auth] interface. -func (a *smtpLoginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) { - // Must have TLS, or else localhost server. - // Note: If TLS is not true, then we can't trust ANYTHING in ServerInfo. - // In particular, it doesn't matter if the server advertises LOGIN auth. - // That might just be the attacker saying - // "it's ok, you can trust me with your password." - if !server.TLS && !isLocalhost(server.Name) { - return "", nil, errors.New("unencrypted connection") - } - - return "LOGIN", nil, nil -} - -// Next "continues" the auth process by feeding the server with the requested data. -// -// It is part of the [smtp.Auth] interface. -func (a *smtpLoginAuth) Next(fromServer []byte, more bool) ([]byte, error) { - if more { - switch strings.ToLower(string(fromServer)) { - case "username:": - return []byte(a.username), nil - case "password:": - return []byte(a.password), nil - } - } - - return nil, nil -} - -func isLocalhost(name string) bool { - return name == "localhost" || name == "127.0.0.1" || name == "::1" -} diff --git a/internal/mailer/smtp_test.go b/internal/mailer/smtp_test.go deleted file mode 100644 index 072ae14..0000000 --- a/internal/mailer/smtp_test.go +++ /dev/null @@ -1,164 +0,0 @@ -package mailer - -import ( - "net/smtp" - "testing" -) - -func TestLoginAuthStart(t *testing.T) { - auth := smtpLoginAuth{username: "test", password: "123456"} - - scenarios := []struct { - name string - serverInfo *smtp.ServerInfo - expectError bool - }{ - { - "localhost without tls", - &smtp.ServerInfo{TLS: false, Name: "localhost"}, - false, - }, - { - "localhost with tls", - &smtp.ServerInfo{TLS: true, Name: "localhost"}, - false, - }, - { - "127.0.0.1 without tls", - &smtp.ServerInfo{TLS: false, Name: "127.0.0.1"}, - false, - }, - { - "127.0.0.1 with tls", - &smtp.ServerInfo{TLS: false, Name: "127.0.0.1"}, - false, - }, - { - "::1 without tls", - &smtp.ServerInfo{TLS: false, Name: "::1"}, - false, - }, - { - "::1 with tls", - &smtp.ServerInfo{TLS: false, Name: "::1"}, - false, - }, - { - "non-localhost without tls", - &smtp.ServerInfo{TLS: false, Name: "example.com"}, - true, - }, - { - "non-localhost with tls", - &smtp.ServerInfo{TLS: true, Name: "example.com"}, - false, - }, - } - - for _, s := range scenarios { - method, resp, err := auth.Start(s.serverInfo) - - hasErr := err != nil - if hasErr != s.expectError { - t.Fatalf("[%s] Expected hasErr %v, got %v", s.name, s.expectError, hasErr) - } - - if hasErr { - continue - } - - if len(resp) != 0 { - t.Fatalf("[%s] Expected empty data response, got %v", s.name, resp) - } - - if method != "LOGIN" { - t.Fatalf("[%s] Expected LOGIN, got %v", s.name, method) - } - } -} - -func TestLoginAuthNext(t *testing.T) { - auth := smtpLoginAuth{username: "test", password: "123456"} - - { - // example|false - r1, err := auth.Next([]byte("example:"), false) - if err != nil { - t.Fatalf("[example|false] Unexpected error %v", err) - } - if len(r1) != 0 { - t.Fatalf("[example|false] Expected empty part, got %v", r1) - } - - // example|true - r2, err := auth.Next([]byte("example:"), true) - if err != nil { - t.Fatalf("[example|true] Unexpected error %v", err) - } - if len(r2) != 0 { - t.Fatalf("[example|true] Expected empty part, got %v", r2) - } - } - - // --------------------------------------------------------------- - - { - // username:|false - r1, err := auth.Next([]byte("username:"), false) - if err != nil { - t.Fatalf("[username|false] Unexpected error %v", err) - } - if len(r1) != 0 { - t.Fatalf("[username|false] Expected empty part, got %v", r1) - } - - // username:|true - r2, err := auth.Next([]byte("username:"), true) - if err != nil { - t.Fatalf("[username|true] Unexpected error %v", err) - } - if str := string(r2); str != auth.username { - t.Fatalf("[username|true] Expected %s, got %s", auth.username, str) - } - - // uSeRnAmE:|true - r3, err := auth.Next([]byte("uSeRnAmE:"), true) - if err != nil { - t.Fatalf("[uSeRnAmE|true] Unexpected error %v", err) - } - if str := string(r3); str != auth.username { - t.Fatalf("[uSeRnAmE|true] Expected %s, got %s", auth.username, str) - } - } - - // --------------------------------------------------------------- - - { - // password:|false - r1, err := auth.Next([]byte("password:"), false) - if err != nil { - t.Fatalf("[password|false] Unexpected error %v", err) - } - if len(r1) != 0 { - t.Fatalf("[password|false] Expected empty part, got %v", r1) - } - - // password:|true - r2, err := auth.Next([]byte("password:"), true) - if err != nil { - t.Fatalf("[password|true] Unexpected error %v", err) - } - if str := string(r2); str != auth.password { - t.Fatalf("[password|true] Expected %s, got %s", auth.password, str) - } - - // pAsSwOrD:|true - r3, err := auth.Next([]byte("pAsSwOrD:"), true) - if err != nil { - t.Fatalf("[pAsSwOrD|true] Unexpected error %v", err) - } - if str := string(r3); str != auth.password { - t.Fatalf("[pAsSwOrD|true] Expected %s, got %s", auth.password, str) - } - } -} diff --git a/plugin/httpgetter/html_meta.go b/plugin/httpgetter/html_meta.go new file mode 100644 index 0000000..f69bad6 --- /dev/null +++ b/plugin/httpgetter/html_meta.go @@ -0,0 +1,98 @@ +package httpgetter + +import ( + "errors" + "io" + "net/http" + "net/url" + + "golang.org/x/net/html" + "golang.org/x/net/html/atom" +) + +type HTMLMeta struct { + Title string `json:"title"` + Description string `json:"description"` + Image string `json:"image"` +} + +func GetHTMLMeta(urlStr string) (*HTMLMeta, error) { + if _, err := url.Parse(urlStr); err != nil { + return nil, err + } + + response, err := http.Get(urlStr) + if err != nil { + return nil, err + } + defer response.Body.Close() + + mediatype, err := getMediatype(response) + if err != nil { + return nil, err + } + if mediatype != "text/html" { + return nil, errors.New("not a HTML page") + } + + htmlMeta := extractHTMLMeta(response.Body) + return htmlMeta, nil +} + +func extractHTMLMeta(resp io.Reader) *HTMLMeta { + tokenizer := html.NewTokenizer(resp) + htmlMeta := new(HTMLMeta) + + for { + tokenType := tokenizer.Next() + if tokenType == html.ErrorToken { + break + } else if tokenType == html.StartTagToken || tokenType == html.SelfClosingTagToken { + token := tokenizer.Token() + if token.DataAtom == atom.Body { + break + } + + if token.DataAtom == atom.Title { + tokenizer.Next() + token := tokenizer.Token() + htmlMeta.Title = token.Data + } else if token.DataAtom == atom.Meta { + description, ok := extractMetaProperty(token, "description") + if ok { + htmlMeta.Description = description + } + + ogTitle, ok := extractMetaProperty(token, "og:title") + if ok { + htmlMeta.Title = ogTitle + } + + ogDescription, ok := extractMetaProperty(token, "og:description") + if ok { + htmlMeta.Description = ogDescription + } + + ogImage, ok := extractMetaProperty(token, "og:image") + if ok { + htmlMeta.Image = ogImage + } + } + } + } + + return htmlMeta +} + +func extractMetaProperty(token html.Token, prop string) (content string, ok bool) { + content, ok = "", false + for _, attr := range token.Attr { + if attr.Key == "property" && attr.Val == prop { + ok = true + } + if attr.Key == "content" { + content = attr.Val + } + } + return content, ok +} diff --git a/plugin/httpgetter/html_meta_test.go b/plugin/httpgetter/html_meta_test.go new file mode 100644 index 0000000..45345d3 --- /dev/null +++ b/plugin/httpgetter/html_meta_test.go @@ -0,0 +1,19 @@ +package httpgetter + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGetHTMLMeta(t *testing.T) { + tests := []struct { + urlStr string + htmlMeta HTMLMeta + }{} + for _, test := range tests { + metadata, err := GetHTMLMeta(test.urlStr) + require.NoError(t, err) + require.Equal(t, test.htmlMeta, *metadata) + } +} diff --git a/plugin/httpgetter/http_getter.go b/plugin/httpgetter/http_getter.go new file mode 100644 index 0000000..c545baf --- /dev/null +++ b/plugin/httpgetter/http_getter.go @@ -0,0 +1,4 @@ +// Package httpgetter is using to get resources from url. +// * Get metadata for website; +// * Get image blob to avoid CORS; +package httpgetter diff --git a/plugin/httpgetter/image.go b/plugin/httpgetter/image.go new file mode 100644 index 0000000..2d6a163 --- /dev/null +++ b/plugin/httpgetter/image.go @@ -0,0 +1,45 @@ +package httpgetter + +import ( + "errors" + "io" + "net/http" + "net/url" + "strings" +) + +type Image struct { + Blob []byte + Mediatype string +} + +func GetImage(urlStr string) (*Image, error) { + if _, err := url.Parse(urlStr); err != nil { + return nil, err + } + + response, err := http.Get(urlStr) + if err != nil { + return nil, err + } + defer response.Body.Close() + + mediatype, err := getMediatype(response) + if err != nil { + return nil, err + } + if !strings.HasPrefix(mediatype, "image/") { + return nil, errors.New("Wrong image mediatype") + } + + bodyBytes, err := io.ReadAll(response.Body) + if err != nil { + return nil, err + } + + image := &Image{ + Blob: bodyBytes, + Mediatype: mediatype, + } + return image, nil +} diff --git a/plugin/httpgetter/util.go b/plugin/httpgetter/util.go new file mode 100644 index 0000000..d83f5ef --- /dev/null +++ b/plugin/httpgetter/util.go @@ -0,0 +1,15 @@ +package httpgetter + +import ( + "mime" + "net/http" +) + +func getMediatype(response *http.Response) (string, error) { + contentType := response.Header.Get("content-type") + mediatype, _, err := mime.ParseMediaType(contentType) + if err != nil { + return "", err + } + return mediatype, nil +}