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
+}