Skip to content

Commit

Permalink
Add SMTP email alerting channel (#42)
Browse files Browse the repository at this point in the history
  • Loading branch information
wanliqun authored May 22, 2024
1 parent 6ed9a9b commit 1bd8b03
Show file tree
Hide file tree
Showing 6 changed files with 352 additions and 7 deletions.
1 change: 1 addition & 0 deletions alert/alert.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ type ChannelType string
const (
ChannelTypeDingTalk ChannelType = "dingtalk"
ChannelTypeTelegram ChannelType = "telegram"
ChannelTypeSMTP ChannelType = "smtp"
)

// Notification channel interface.
Expand Down
133 changes: 128 additions & 5 deletions alert/formatter.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,76 @@ type Formatter interface {
Format(note *Notification) (string, error)
}

type markdownFormatter struct {
type tplFormatter struct {
tags []string

defaultTpl *template.Template
logEntryTpl *template.Template
}

func newTplFormatter(
tags []string, defaultTpl, logEntryTpl *template.Template) *tplFormatter {
return &tplFormatter{
tags: tags, defaultTpl: defaultTpl, logEntryTpl: logEntryTpl,
}
}

func (f *tplFormatter) Format(note *Notification) (string, error) {
if _, ok := note.Content.(*logrus.Entry); ok {
return f.formatLogrusEntry(note)
}

return f.formatDefault(note)
}

func (f *tplFormatter) formatLogrusEntry(note *Notification) (string, error) {
entry := note.Content.(*logrus.Entry)
entryError, _ := entry.Data[logrus.ErrorKey].(error)

ctxFields := make(map[string]interface{})
for k, v := range entry.Data {
if k == logrus.ErrorKey {
continue
}
ctxFields[k] = v
}

buffer := bytes.Buffer{}
err := f.logEntryTpl.Execute(&buffer, struct {
Level logrus.Level
Tags []string
Time time.Time
Msg string
Error error
CtxFields map[string]interface{}
}{entry.Level, f.tags, entry.Time, entry.Message, entryError, ctxFields})
if err != nil {
return "", errors.WithMessage(err, "template exec error")
}

return buffer.String(), nil
}

func (f *tplFormatter) formatDefault(note *Notification) (string, error) {
buffer := bytes.Buffer{}
err := f.defaultTpl.Execute(&buffer, struct {
Title string
Tags []string
Severity Severity
Time time.Time
Content interface{}
}{note.Title, f.tags, note.Severity, time.Now(), note.Content})
if err != nil {
return "", errors.WithMessage(err, "template exec error")
}

return buffer.String(), nil
}

type markdownFormatter struct {
*tplFormatter
}

func newMarkdownFormatter(
tags []string, funcMap template.FuncMap, defaultStrTpl, logEntryStrTpl string,
) (f *markdownFormatter, err error) {
Expand All @@ -33,14 +96,12 @@ func newMarkdownFormatter(
for i := range strTemplates {
tpls[i], err = template.New("markdown").Funcs(funcMap).Parse(strTemplates[i])
if err != nil {
return nil, errors.WithMessage(err, "bad template")
return nil, errors.WithMessage(err, "bad markdown template")
}
}

return &markdownFormatter{
tags: tags,
defaultTpl: tpls[0],
logEntryTpl: tpls[1],
tplFormatter: newTplFormatter(tags, tpls[0], tpls[1]),
}, nil
}

Expand Down Expand Up @@ -139,6 +200,68 @@ func escapeMarkdown(v interface{}) string {
return bot.EscapeMarkdown(fmt.Sprintf("%v", v))
}

type htmlFormatter struct {
*tplFormatter
}

func newHtmlFormatter(
tags []string, funcMap template.FuncMap, defaultStrTpl, logEntryStrTpl string,
) (f *htmlFormatter, err error) {
var tpls [2]*template.Template

strTemplates := [2]string{defaultStrTpl, logEntryStrTpl}
for i := range strTemplates {
tpls[i], err = template.New("html").Funcs(funcMap).Parse(strTemplates[i])
if err != nil {
return nil, errors.WithMessage(err, "bad html template")
}
}

return &htmlFormatter{
tplFormatter: newTplFormatter(tags, tpls[0], tpls[1]),
}, nil
}

type SmtpHtmlFormatter struct {
*htmlFormatter
conf SmtpConfig
}

func NewSmtpHtmlFormatter(
conf SmtpConfig, tags []string) (f *SmtpHtmlFormatter, err error) {
funcMap := template.FuncMap{
"formatRFC3339": formatRFC3339,
}
hf, err := newHtmlFormatter(
tags, funcMap, htmlTemplates[0], htmlTemplates[1],
)
if err != nil {
return nil, err
}

return &SmtpHtmlFormatter{conf: conf, htmlFormatter: hf}, nil
}

func (f *SmtpHtmlFormatter) Format(note *Notification) (msg string, err error) {
body, err := f.htmlFormatter.Format(note)
if err != nil {
return "", err
}

header := make(map[string]string)
header["From"] = f.conf.From
header["To"] = strings.Join(f.conf.To, ";")
header["Subject"] = note.Title
header["Content-Type"] = "text/html; charset=UTF-8"

for k, v := range header {
msg += fmt.Sprintf("%s: %s\r\n", k, v)
}

msg += "\r\n" + body
return msg, nil
}

type SimpleTextFormatter struct {
tags []string
}
Expand Down
161 changes: 161 additions & 0 deletions alert/smtp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package alert

import (
"context"
"crypto/tls"
"io"
"net"
"net/smtp"
"time"

"github.com/pkg/errors"
)

var (
_ Channel = (*SmtpChannel)(nil)
)

type SmtpConfig struct {
Host string // SMTP endpoint and port
From string // Sender address
To []string // Receipt addresses
Password string // SMTP password
}

// SmtpChannel represents a SMTP email notification channel
type SmtpChannel struct {
Formatter Formatter // Formatter is used to format the notification message
ID string // ID is the identifier of the channel
Config SmtpConfig // Config contains the configuration for the SMTP server
}

// NewSmtpChannel creates a new SMTP channel with the given ID, formatter, and configuration
func NewSmtpChannel(chID string, fmtter Formatter, conf SmtpConfig) *SmtpChannel {
return &SmtpChannel{ID: chID, Formatter: fmtter, Config: conf}
}

// Name returns the ID of the channel
func (c *SmtpChannel) Name() string {
return c.ID
}

// Type returns the type of the channel, which is SMTP
func (c *SmtpChannel) Type() ChannelType {
return ChannelTypeSMTP
}

// Send sends a notification using the SMTP channel
func (c *SmtpChannel) Send(ctx context.Context, note *Notification) error {
// Format the notification message
msg, err := c.Formatter.Format(note)
if err != nil {
return errors.WithMessage(err, "failed to format alert msg")
}
// Send the formatted message
return c.send(ctx, msg)
}

// send sends a message using the SMTP channel
func (c *SmtpChannel) send(ctx context.Context, msg string) error {
// Dial the SMTP server
client, err := c.dial(ctx)
if err != nil {
return errors.WithMessage(err, "failed to dial smtp server")
}

// Close the client when done
defer client.Close()

// Authenticate with the SMTP server if it supports authentication
if ok, _ := client.Extension("AUTH"); ok {
auth := smtp.PlainAuth("", c.Config.From, c.Config.Password, c.Config.Host)
if err := c.doWithDeadlineCheck(ctx, func() error { return client.Auth(auth) }); err != nil {
return errors.WithMessage(err, "failed to authenticate smtp server")
}
}

// Set the sender
if err := c.doWithDeadlineCheck(ctx, func() error { return client.Mail(c.Config.From) }); err != nil {
return errors.WithMessage(err, "failed to set sender")
}

// Add the recipients
for _, addr := range c.Config.To {
if err := c.doWithDeadlineCheck(ctx, func() error { return client.Rcpt(addr) }); err != nil {
return errors.WithMessagef(err, "failed to add recipient %s", addr)
}
}

// Write the message
var w io.WriteCloser
if err := c.doWithDeadlineCheck(ctx, func() error { w, err = client.Data(); return err }); err != nil {
return errors.WithMessage(err, "failed to open data writer")
}

if err := c.doWithDeadlineCheck(ctx, func() error { _, err = w.Write([]byte(msg)); return err }); err != nil {
return errors.WithMessage(err, "failed to write data")
}

// Close the writer
if err := w.Close(); err != nil {
return errors.WithMessage(err, "failed to close data writer")
}

// Quit the SMTP session
if err := c.doWithDeadlineCheck(ctx, func() error { return client.Quit() }); err != nil {
return errors.WithMessage(err, "failed to quit smtp session")
}

return nil
}

// doWithDeadlineCheck checks if the deadline has been exceeded before performing an operation
func (c *SmtpChannel) doWithDeadlineCheck(ctx context.Context, op func() error) error {
if deadlineExceeded(ctx) {
return context.DeadlineExceeded
}

if err := op(); err != nil {
return err
}

return nil
}

// deadlineExceeded checks if the deadline of the context has been exceeded
func deadlineExceeded(ctx context.Context) bool {
if _, ok := ctx.Deadline(); ok {
select {
case <-ctx.Done():
return true
default:
}
}
return false
}

// dial dials the SMTP server and returns a new SMTP client
func (c *SmtpChannel) dial(ctx context.Context) (*smtp.Client, error) {
var dialer *net.Dialer

// Check if a deadline has been set
deadline, ok := ctx.Deadline()
if ok { // deadline set?
timeout := time.Until(deadline)
if timeout < 0 { // timeout exceeded
return nil, context.DeadlineExceeded
}
dialer = &net.Dialer{Timeout: timeout}
} else {
dialer = new(net.Dialer)
}

// Dial the SMTP server
conn, err := tls.DialWithDialer(dialer, "tcp", c.Config.Host, nil)
if err != nil {
return nil, err
}

// Create a new SMTP client
return smtp.NewClient(conn, c.Config.Host)
}
35 changes: 35 additions & 0 deletions alert/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,41 @@ var (
{{else}}{{ end }}{{ if .CtxFields }}*Context Fields*:{{ range $Key, $Val := .CtxFields }}
*{{$Key | escapeMarkdown}}*: {{$Val | escapeMarkdown}}{{ end }}{{ end }}
`,
}

htmlTemplates = []string{
`{{- /* default HTML template */ -}}
<h1>{{.Title}}</h1>
<ul>
<li><b>Tags</b>: {{.Tags}}</li>
<li><b>Severity</b>: {{.Severity}}</li>
<li><b>Time</b>: {{.Time | formatRFC3339}}</li>
</ul>
<p>{{.Content}}</p>
`,
`{{- /* logrus entry HTML template */ -}}
<h1>{{.Level}}</h1>
<ul>
<li><b>Tags</b>: {{.Tags}}</li>
<li><b>Time</b>: {{.Time | formatRFC3339}}</li>
</ul>
<hr/>
<h2>Message</h2>
<p>{{.Msg}}</p>
{{with .Error}}
<hr/>
<h2>Reason</h2>
<p>{{.Error}}</p>
{{ end }}
{{ if .CtxFields }}
<hr/>
<h2>Context Fields</h2>
<ul>
{{ range $Key, $Val := .CtxFields }}
<li><b>{{$Key}}</b>: {{$Val}}</li>
{{ end }}
{{ end }}
`,
}
)
Loading

0 comments on commit 1bd8b03

Please sign in to comment.