Compare commits

..

No commits in common. "main" and "1.2.0" have entirely different histories.
main ... 1.2.0

11 changed files with 191 additions and 271 deletions

View file

@ -34,28 +34,27 @@ Example:
```json ```json
{ {
"api_fetch_interval": 600, "api_fetch_interval": 600,
"datafile": "data.json",
"enabled_api_endpoints": [ "enabled_api_endpoints": [
"bay", "bay",
"bund" "bund"
], ],
"datafile": "data.json",
"loglevel": 2, "loglevel": 2,
"lists": [ "recipients": [
{ {
"name": "Example List", "address": "guenther@example.org",
"recipients": ["someone@example.org"], "include": [
"filter": [ {"classification": "kritisch"},
{"classification": "hoch", "title_contains": "Microsoft"}, {"title_contains": "jQuery"}
{"classification": "kritisch"}
] ]
} }
], ],
"smtp": { "smtp": {
"from": "user@localhost", "from": "from@example.org",
"host": "127.0.0.1", "host": "example.org",
"port": 587, "port": 587,
"user": "user@localhost", "user": "from@example.org",
"password": "change me :)" "password": "SiEhAbEnMiChInSgEsIcHtGeFiLmTdAsDüRfEnSiEnIcHt"
}, },
"template": { "template": {
"subject": "", "subject": "",

View file

@ -11,7 +11,7 @@ type Config struct {
EnabledApiEndpoints []string `json:"enabled_api_endpoints"` EnabledApiEndpoints []string `json:"enabled_api_endpoints"`
PersistentDataFilePath string `json:"datafile"` PersistentDataFilePath string `json:"datafile"`
LogLevel int `json:"loglevel"` LogLevel int `json:"loglevel"`
Lists *[]NotifyList `json:"lists"` Recipients []Recipient `json:"recipients"`
SmtpConfiguration SmtpSettings `json:"smtp"` SmtpConfiguration SmtpSettings `json:"smtp"`
Template MailTemplateConfig `json:"template"` Template MailTemplateConfig `json:"template"`
} }
@ -23,16 +23,12 @@ func NewConfig() Config {
EnabledApiEndpoints: []string{"bay", "bund"}, EnabledApiEndpoints: []string{"bay", "bund"},
PersistentDataFilePath: "data.json", PersistentDataFilePath: "data.json",
LogLevel: 2, LogLevel: 2,
Lists: &[]NotifyList{ Recipients: []Recipient{},
{ Name: "Example List",
Recipients: []string{},
Filter: []Filter{},},
},
SmtpConfiguration: SmtpSettings{ SmtpConfiguration: SmtpSettings{
From: "user@localhost", From: "from@example.org",
User: "user@localhost", User: "from@example.org",
Password: "change me :)", Password: "SiEhAbEnMiChInSgEsIcHtGeFiLmTdAsDüRfEnSiEnIcHt",
ServerHost: "127.0.0.1", ServerHost: "example.org",
ServerPort: 587}, ServerPort: 587},
Template: MailTemplateConfig{ Template: MailTemplateConfig{
SubjectTemplate: "", SubjectTemplate: "",
@ -43,20 +39,18 @@ func NewConfig() Config {
} }
func checkConfig(config Config) { func checkConfig(config Config) {
if len(*config.Lists) < 1 { if len(config.Recipients) < 1 {
logger.error("Configuration is incomplete") logger.error("Configuration is incomplete")
panic(errors.New("no lists are configured")) panic(errors.New("no recipients are configured"))
} }
for _, l := range *config.Lists { for _, r := range config.Recipients {
if len(l.Filter) < 1 { if !mailAddressIsValid(r.Address) {
logger.error("Configuration is incomplete") logger.error("Configuration includes invalid data")
panic(errors.New("list " + l.Name + " has no filter defined - at least [{'any': true/false}] should be configured")) panic(errors.New("'" + r.Address + "' is not a valid e-mail address"))
} }
for _, r := range l.Recipients { if len(r.Filters) < 1 {
if !mailAddressIsValid(r) { logger.error("Configuration is incomplete")
logger.error("Configuration includes invalid data") panic(errors.New("recipient " + r.Address + " has no filter defined - at least {'any': true/false} should be configured"))
panic(errors.New("'" + r + "' is not a valid e-mail address"))
}
} }
} }
if !mailAddressIsValid(config.SmtpConfiguration.From) { if !mailAddressIsValid(config.SmtpConfiguration.From) {

2
go.mod
View file

@ -1,3 +1,3 @@
module github.com/ChaoticByte/wid-notifier module github.com/ChaoticByte/wid-notifier
go 1.24 go 1.22

133
mail.go Normal file
View file

@ -0,0 +1,133 @@
// Copyright (c) 2023 Julian Müller (ChaoticByte)
package main
import (
"crypto/tls"
"encoding/base64"
"fmt"
"net/mail"
"net/smtp"
"time"
)
const MAIL_LINE_SEP = "\r\n"
type MailContent struct {
Subject string
Body string
}
func (c MailContent) serializeValidMail(from string, to string) []byte {
// We'll send base64 encoded Subject & Body, because we Dschörmäns have umlauts
// and I'm too lazy to encode ä into =E4 and so on
subjectEncoded := base64.StdEncoding.EncodeToString([]byte(c.Subject))
bodyEncoded := base64.StdEncoding.EncodeToString([]byte(c.Body))
data := []byte(fmt.Sprintf(
"Content-Type: text/plain; charset=\"utf-8\"\r\nContent-Transfer-Encoding: base64\r\nFrom: %v%vTo: %v%vSubject: =?utf-8?b?%v?=%v%v%v",
from, MAIL_LINE_SEP,
to, MAIL_LINE_SEP,
subjectEncoded, MAIL_LINE_SEP,
MAIL_LINE_SEP,
bodyEncoded))
// done, I guess
return data
}
type Recipient struct {
Address string `json:"address"`
// Must be a configured filter id
Filters []Filter `json:"include"`
}
type SmtpSettings struct {
From string `json:"from"`
ServerHost string `json:"host"`
ServerPort int `json:"port"`
User string `json:"user"`
Password string `json:"password"`
}
func (r Recipient) sendNotices(notices []WidNotice, template MailTemplate, auth smtp.Auth, smtpConfig SmtpSettings, cache *map[string][]byte) error {
logger.debug("Generating and sending mails to " + r.Address + " ...")
cacheHits := 0
cacheMisses := 0
mails := [][]byte{}
for _, n := range notices {
var data []byte
cacheResult := (*cache)[n.Uuid]
if len(cacheResult) > 0 {
cacheHits++
data = cacheResult
} else {
cacheMisses++
mailContent, err := template.generate(TemplateData{n, Version})
if err != nil {
logger.error("Could not create mail from template")
logger.error(err)
}
// serialize & send mail
data = mailContent.serializeValidMail(smtpConfig.From, r.Address)
// add to cache
(*cache)[n.Uuid] = data
}
mails = append(mails, data)
}
logger.debug(fmt.Sprintf("%v mail cache hits, %v misses", cacheHits, cacheMisses))
err := sendMails(
smtpConfig,
auth,
r.Address,
mails,
)
if err != nil { return err }
logger.debug("Successfully sent all mails to " + r.Address)
return nil
}
func sendMails(smtpConf SmtpSettings, auth smtp.Auth, to string, data [][]byte) error {
addr := fmt.Sprintf("%v:%v", smtpConf.ServerHost, smtpConf.ServerPort)
logger.debug("Connecting to mail server at " + addr + " ...")
connection, err := smtp.Dial(addr)
if err != nil { return err }
defer connection.Close()
// can leave out connection.Hello
hasTlsExt, _ := connection.Extension("starttls")
if hasTlsExt {
err = connection.StartTLS(&tls.Config{ServerName: smtpConf.ServerHost})
if err != nil { return err }
logger.debug("Mail Server supports TLS")
} else {
logger.debug("Mail Server doesn't support TLS")
}
logger.debug("Authenticating to mail server ...")
err = connection.Auth(auth)
if err != nil { return err }
if logger.LogLevel >= 3 {
fmt.Printf("DEBUG %v Sending mails to server ", time.Now().Format("2006/01/02 15:04:05.000000"))
}
for _, d := range data {
err = connection.Mail(smtpConf.From)
if err != nil { return err }
err = connection.Rcpt(to)
if err != nil { return err }
writer, err := connection.Data()
if err != nil { return err }
_, err = writer.Write(d)
if err != nil { return err }
err = writer.Close()
if err != nil { return err }
if logger.LogLevel >= 3 {
print(".")
}
}
if logger.LogLevel >= 3 {
print("\n")
}
return connection.Quit()
}
func mailAddressIsValid(address string) bool {
_, err := mail.ParseAddress(address);
return err == nil
}

View file

@ -1,90 +0,0 @@
// Copyright (c) 2023 Julian Müller (ChaoticByte)
package main
import (
"fmt"
"mime"
"mime/quotedprintable"
"net/mail"
"net/smtp"
"strings"
)
type MailContent struct {
Subject string
Body string
}
func (c MailContent) serializeValidMail(from string, to string) []byte {
// format subject using Q Encoding from RFC2047
subjectEncoded := mime.QEncoding.Encode("utf-8", c.Subject)
// format body using Quoted-Printable Encoding from RFC2045
var bodyEncoded strings.Builder
bew := quotedprintable.NewWriter(&bodyEncoded)
bew.Write([]byte(c.Body))
bew.Close()
// glue it all together
data := fmt.Appendf(nil,
"Content-Type: text/plain; charset=\"utf-8\"\r\nContent-Transfer-Encoding: Quoted-Printable\r\nFrom: %v\r\nTo: %v\r\nSubject: %v\r\n\r\n%v",
from, to, subjectEncoded, bodyEncoded.String(),
)
return data
}
type NotifyList struct {
Name string `json:"name"`
Recipients []string `json:"recipients"`
// Must be a configured filter id
Filter []Filter `json:"filter"`
}
type SmtpSettings struct {
From string `json:"from"`
ServerHost string `json:"host"`
ServerPort int `json:"port"`
User string `json:"user"`
Password string `json:"password"`
}
func sendNotices(recipient string, notices []*WidNotice, template MailTemplate, auth smtp.Auth, smtpConfig SmtpSettings, mailContentCache *map[string]*MailContent) error {
logger.debug("Generating and sending mails for recipient " + recipient + " ...")
cacheHits := 0
cacheMisses := 0
mails := []*MailContent{}
for _, n := range notices {
var mc *MailContent
cacheResult := (*mailContentCache)[n.Uuid]
if cacheResult != nil {
cacheHits++
mc = cacheResult
} else {
cacheMisses++
mc_, err := template.generate(TemplateData{n, Version})
if err != nil {
logger.error("Could not create mail from template")
logger.error(err)
} else {
mc = &mc_
// add to cache
(*mailContentCache)[n.Uuid] = mc
}
}
mails = append(mails, mc)
}
logger.debug(fmt.Sprintf("%v mail cache hits, %v misses", cacheHits, cacheMisses))
err := sendMails(
smtpConfig,
auth,
recipient,
mails,
)
if err != nil { return err }
logger.debug("Successfully sent all mails to " + recipient)
return nil
}
func mailAddressIsValid(address string) bool {
_, err := mail.ParseAddress(address);
return err == nil
}

View file

@ -1,57 +0,0 @@
// +build !debug_mail_transfer
// Copyright (c) 2023 Julian Müller (ChaoticByte)
package main
import (
"crypto/tls"
"fmt"
"net/smtp"
"time"
)
func sendMails(smtpConf SmtpSettings, auth smtp.Auth, to string, mails []*MailContent) error {
addr := fmt.Sprintf("%v:%v", smtpConf.ServerHost, smtpConf.ServerPort)
logger.debug("Connecting to mail server at " + addr + " ...")
connection, err := smtp.Dial(addr)
if err != nil { return err }
defer connection.Close()
// can leave out connection.Hello
hasTlsExt, _ := connection.Extension("starttls")
if hasTlsExt {
err = connection.StartTLS(&tls.Config{ServerName: smtpConf.ServerHost})
if err != nil { return err }
logger.debug("Mail Server supports StartTLS")
} else {
logger.debug("Mail Server doesn't support StartTLS")
}
logger.debug("Authenticating to mail server ...")
err = connection.Auth(auth)
if err != nil { return err }
if logger.LogLevel >= 3 {
fmt.Printf("DEBUG %v Sending mails to server ", time.Now().Format("2006/01/02 15:04:05.000000"))
}
for _, mc := range mails {
// serialize mail
d := mc.serializeValidMail(smtpConf.From, to)
// send mail
err = connection.Mail(smtpConf.From)
if err != nil { return err }
err = connection.Rcpt(to)
if err != nil { return err }
writer, err := connection.Data()
if err != nil { return err }
_, err = writer.Write(d)
if err != nil { return err }
err = writer.Close()
if err != nil { return err }
if logger.LogLevel >= 3 {
print(".")
}
}
if logger.LogLevel >= 3 {
print("\n")
}
return connection.Quit()
}

View file

@ -1,26 +0,0 @@
// +build debug_mail_transfer
// Copyright (c) 2023 Julian Müller (ChaoticByte)
package main
import (
"fmt"
"net/smtp"
)
func sendMails(smtpConf SmtpSettings, auth smtp.Auth, to string, mails []*MailContent) error {
logger.warn("Mail Transfer Debugging is active. Not connecting.")
logger.info("MAIL TRANSFER: \n\n")
for _, mc := range mails {
// serialize mail
d := mc.serializeValidMail(smtpConf.From, to)
// output mail
fmt.Println("MAIL FROM:" + smtpConf.From)
fmt.Println("RCPT TO:" + to)
fmt.Println("DATA")
fmt.Println(string(d))
fmt.Println(".")
}
fmt.Print("\n\n")
return nil
}

81
main.go
View file

@ -10,39 +10,19 @@ import (
"time" "time"
) )
var executableName string
var logger Logger var logger Logger
func showVersion() {
fmt.Printf("wid-notifier %s\n", Version)
}
func showHelp() {
fmt.Printf("Usage: %v <configfile>\n\nIf the config file doesn't exist, an incomplete \n" +
"configuration with default values is created.\n\n",
executableName)
showVersion()
}
func main() { func main() {
// get cli arguments // get cli arguments
args := os.Args args := os.Args
executableName = args[0]
if len(args) < 2 { if len(args) < 2 {
showHelp() fmt.Printf( "Usage: %v <configfile>\n\nIf the config file doesn't exist, an incomplete \n" +
"configuration with default values is created.\n\nVersion: %s\n",
args[0],
Version)
os.Exit(1) os.Exit(1)
} }
for _, arg := range args { configFilePath := os.Args[1]
if arg == "-h" || arg == "--help" {
showHelp()
os.Exit(0)
} else if arg == "--version" {
showVersion()
os.Exit(0)
}
}
configFilePath := args[1]
// create logger // create logger
logger = NewLogger(2) logger = NewLogger(2)
// init // init
@ -97,8 +77,7 @@ func main() {
for { for {
t1 := time.Now().UnixMilli() t1 := time.Now().UnixMilli()
newNotices := []WidNotice{} newNotices := []WidNotice{}
lastPublished := map[string]time.Time{} // endpoint id : last published timestamp cache := map[string][]byte{}
cache := map[string]*MailContent{} // cache generated emails for reuse
for _, a := range enabledApiEndpoints { for _, a := range enabledApiEndpoints {
logger.info("Querying endpoint '" + a.Id + "' for new notices ...") logger.info("Querying endpoint '" + a.Id + "' for new notices ...")
n, t, err := a.getNotices(persistent.data.(PersistentData).LastPublished[a.Id]) n, t, err := a.getNotices(persistent.data.(PersistentData).LastPublished[a.Id])
@ -114,57 +93,35 @@ func main() {
logger.error(err) logger.error(err)
} else if len(n) > 0 { } else if len(n) > 0 {
newNotices = append(newNotices, n...) newNotices = append(newNotices, n...)
lastPublished[a.Id] = t persistent.data.(PersistentData).LastPublished[a.Id] = t
persistent.save()
} }
} }
logger.debug(fmt.Sprintf("Got %v new notices", len(newNotices))) logger.debug(fmt.Sprintf("Got %v new notices", len(newNotices)))
if len(newNotices) > 0 { if len(newNotices) > 0 {
logger.info("Sending email notifications ...") logger.info("Sending email notifications ...")
// mail recipient : pointer to slice of wid notices to be sent
noticesToBeSent := map[string][]*WidNotice{}
recipientsNotified := 0 recipientsNotified := 0
var err error for _, r := range config.Recipients {
for _, l := range *config.Lists { // Filter notices for this recipient
// Filter notices for this list filteredNotices := []WidNotice{}
for _, f := range l.Filter { for _, f := range r.Filters {
for _, n := range f.filter(newNotices) { for _, n := range f.filter(newNotices) {
np := &n if !noticeSliceContains(filteredNotices, n) {
for _, r := range l.Recipients { filteredNotices = append(filteredNotices, n)
if !noticeSliceContains(noticesToBeSent[r], np) {
noticesToBeSent[r] = append(noticesToBeSent[r], np)
}
} }
} }
} }
} slices.Reverse(filteredNotices)
for r, notices := range noticesToBeSent { logger.debug(fmt.Sprintf("Including %v of %v notices for recipient %v", len(filteredNotices), len(newNotices), r.Address))
// sort by publish date // Send notices
slices.SortFunc(notices, func(a *WidNotice, b *WidNotice) int { err := r.sendNotices(filteredNotices, mailTemplate, mailAuth, config.SmtpConfiguration, &cache)
if a.Published == b.Published {
return 0
} else if a.Published.After(b.Published) {
return 1
} else {
return -1
}
})
// send
err = sendNotices(r, notices, mailTemplate, mailAuth, config.SmtpConfiguration, &cache)
if err != nil { if err != nil {
logger.error(err) logger.error(err)
} else { } else {
recipientsNotified++ recipientsNotified++
} }
} }
if recipientsNotified < 1 && err != nil { logger.info(fmt.Sprintf("Email notifications sent to %v of %v recipients", recipientsNotified, len(config.Recipients)))
logger.error("Couldn't send any mail notification!")
} else {
for id, t := range lastPublished {
persistent.data.(PersistentData).LastPublished[id] = t
persistent.save()
}
logger.info(fmt.Sprintf("Email notifications sent to %v of %v recipients", recipientsNotified, len(noticesToBeSent)))
}
} }
dt := int(time.Now().UnixMilli() - t1) dt := int(time.Now().UnixMilli() - t1)
time.Sleep(time.Millisecond * time.Duration((config.ApiFetchInterval * 1000) - dt)) time.Sleep(time.Millisecond * time.Duration((config.ApiFetchInterval * 1000) - dt))

View file

@ -25,7 +25,17 @@ type WidNotice struct {
PortalUrl string PortalUrl string
} }
func noticeSliceContains(notices []*WidNotice, notice *WidNotice) bool { // func (n WidNotice) serialized() ([]byte, error) {
// return json.Marshal(n)
// }
// func NewWidNoticeFromJSON(data []byte) (WidNotice, error) {
// n := WidNotice{}
// err := json.Unmarshal(data, &n)
// return n, err
// }
func noticeSliceContains(notices []WidNotice, notice WidNotice) bool {
for _, x := range notices { for _, x := range notices {
if x.Uuid == notice.Uuid { if x.Uuid == notice.Uuid {
return true return true

View file

@ -18,4 +18,4 @@ func NewPersistentData(c Config) PersistentData {
d.LastPublished[e.Id] = time.Now().Add(-time.Hour * 24) // a day ago d.LastPublished[e.Id] = time.Now().Add(-time.Hour * 24) // a day ago
} }
return d return d
} }

View file

@ -33,7 +33,7 @@ Sent by WidNotifier {{ .WidNotifierVersion }}
` `
type TemplateData struct { type TemplateData struct {
*WidNotice WidNotice
WidNotifierVersion string WidNotifierVersion string
} }