diff --git a/cmd/opts.go b/cmd/opts.go index 92a9c9d..511ed32 100644 --- a/cmd/opts.go +++ b/cmd/opts.go @@ -30,6 +30,14 @@ type composeActionOpts struct { ci bool } +type sendActionOpts struct { + *Options + + config string + reportTimestamp cli.Timestamp + ci bool +} + type genActionOpts struct { *Options diff --git a/cmd/send.go b/cmd/send.go new file mode 100644 index 0000000..7fab270 --- /dev/null +++ b/cmd/send.go @@ -0,0 +1,98 @@ +package cmd + +import ( + "fmt" + "path/filepath" + "time" + + "github.com/pPrecel/PKUP/internal/logo" + "github.com/pPrecel/PKUP/pkg/config" + "github.com/pPrecel/PKUP/pkg/period" + "github.com/pPrecel/PKUP/pkg/report" + "github.com/pPrecel/PKUP/pkg/send" + "github.com/pterm/pterm" + "github.com/urfave/cli/v2" +) + +func NewSendCommand(opts *Options) *cli.Command { + _, until := period.GetCurrentPKUP() + actionOpts := &sendActionOpts{ + Options: opts, + reportTimestamp: *cli.NewTimestamp(until), + } + return &cli.Command{ + Name: "send", + Usage: "Send emails with generated reports based on the config", + UsageText: "pkup send --config .pkupcompose.yaml", + Aliases: []string{"s"}, + Before: func(_ *cli.Context) error { + // print logo before any action + fmt.Printf("%s\n\n", logo.Build(opts.BuildVersion)) + + return nil + }, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "config", + Value: ".pkupcompose.yaml", + Destination: &actionOpts.config, + Action: func(_ *cli.Context, path string) error { + path, err := filepath.Abs(path) + if err != nil { + return err + } + + actionOpts.config = path + return nil + }, + }, + &cli.TimestampFlag{ + Name: "timestamp", + Usage: "timestamp used to create zip file suffix base on month and year" + report.PeriodFormat, + Layout: report.PeriodFormat, + Timezone: time.Local, + Action: func(_ *cli.Context, t *time.Time) error { + actionOpts.reportTimestamp.SetTimestamp(t.Add(time.Hour*24 - time.Second)) + return nil + }, + }, + &cli.BoolFlag{ + Name: "v", + Usage: "verbose log mode", + DisableDefaultText: true, + Category: loggingCategory, + Action: func(_ *cli.Context, _ bool) error { + opts.Log.Level = pterm.LogLevelDebug + return nil + }, + }, + &cli.BoolFlag{ + Name: "vv", + Usage: "trace log mode", + DisableDefaultText: true, + Category: loggingCategory, + Action: func(_ *cli.Context, _ bool) error { + opts.Log.Level = pterm.LogLevelTrace + return nil + }, + }, + }, + Action: func(_ *cli.Context) error { + return sendCommandAction(actionOpts) + }, + } +} + +func sendCommandAction(opts *sendActionOpts) error { + opts.Log.Info("sending reports for", opts.Log.Args( + "config", opts.config, + )) + + config, err := config.Read(opts.config) + if err != nil { + return fmt.Errorf("failed to read config from path '%s': %s", opts.config, err.Error()) + } + + zipSuffix := opts.reportTimestamp.Value().Format("_01_2006") + return send.New(opts.Log).ForConfig(config, zipSuffix) +} diff --git a/go.mod b/go.mod index 5fd5f64..4ab4ed4 100644 --- a/go.mod +++ b/go.mod @@ -11,10 +11,14 @@ require ( github.com/stretchr/testify v1.8.4 github.com/urfave/cli/v2 v2.25.7 github.com/zalando/go-keyring v0.2.3 + gopkg.in/mail.v2 v2.3.1 k8s.io/utils v0.0.0-20230726121419-3b25d923346b ) -require github.com/hashicorp/errwrap v1.0.0 // indirect +require ( + github.com/hashicorp/errwrap v1.0.0 // indirect + gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect +) require ( atomicgo.dev/cursor v0.2.0 // indirect diff --git a/go.sum b/go.sum index de0dfe4..6f30f96 100644 --- a/go.sum +++ b/go.sum @@ -177,9 +177,13 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw= google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk= +gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index af3ab24..798bf19 100644 --- a/main.go +++ b/main.go @@ -49,6 +49,7 @@ func main() { cmd.NewGenCommand(opts), cmd.NewComposeCommand(opts), cmd.NewVersionCommand(opts), + cmd.NewSendCommand(opts), }, } diff --git a/pkg/compose/compose.go b/pkg/compose/compose.go index da0f706..fcb12d1 100644 --- a/pkg/compose/compose.go +++ b/pkg/compose/compose.go @@ -47,8 +47,6 @@ type Options struct { } func (c *compose) ForConfig(config *config.Config, opts Options) error { - c.logger.Trace("compose for", c.logger.Args("config", fmt.Sprintf("%+v", *config))) - taskView := view.NewMultiTaskView(c.logger, opts.Ci) viewLogger := c.logger.WithWriter(taskView.NewWriter()) diff --git a/pkg/config/config.go b/pkg/config/config.go index e1b5815..77f3c04 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -2,6 +2,7 @@ package config import ( "os" + "time" "gopkg.in/yaml.v3" ) @@ -11,6 +12,18 @@ type Config struct { Repos []Remote `yaml:"repos,omitempty"` Orgs []Remote `yaml:"orgs,omitempty"` Reports []Report `yaml:"reports,omitempty"` + Send Send `yaml:"send,omitempty"` +} + +type Send struct { + ServerAddress string `yaml:"serverAddress"` + ServerPort int `yaml:"serverPort"` + Username string `yaml:"username"` + Password string `yaml:"password"` + Delay *time.Duration `yaml:"delay,omitempty"` + Subject string `yaml:"subject"` + HTMLBodyPath string `yaml:"htmlBodyPath,omitempty"` + From string `yaml:"from"` } type Remote struct { @@ -24,6 +37,7 @@ type Remote struct { type Report struct { Signatures []Signature `yaml:"signatures,omitempty"` + Email string `yaml:"email,omitempty"` OutputDir string `yaml:"outputDir,omitempty"` ExtraFields map[string]string `yaml:"extraFields,omitempty"` } diff --git a/pkg/send/dial.go b/pkg/send/dial.go new file mode 100644 index 0000000..72fe6dd --- /dev/null +++ b/pkg/send/dial.go @@ -0,0 +1,44 @@ +package send + +import ( + "github.com/pterm/pterm" + "gopkg.in/mail.v2" +) + +type dialer struct { + logger *pterm.Logger + sender mail.SendCloser +} + +func NewDialer(logger *pterm.Logger, address string, port int, username, password string) (*dialer, error) { + d := mail.NewDialer(address, port, username, password) + d.StartTLSPolicy = mail.MandatoryStartTLS + + logger.Trace("dialing to server", logger.Args("address", address, "port", port, "username", username)) + sendCloser, err := d.Dial() + d.DialAndSend() + if err != nil { + return nil, err + } + + return &dialer{ + logger: logger, + sender: sendCloser, + }, nil +} + +func (d *dialer) Close() { + d.logger.Trace("closing dialer") + d.sender.Close() +} + +func (d *dialer) SendMail(from, subject, destination, body, attachmentPath string) error { + message := mail.NewMessage() + message.SetHeader("From", from) + message.SetHeader("To", destination) + message.SetHeader("Subject", subject) + message.SetBody("text/html", body) + message.Attach(attachmentPath) + + return mail.Send(d.sender, message) +} diff --git a/pkg/send/email.go b/pkg/send/email.go new file mode 100644 index 0000000..48c0f4d --- /dev/null +++ b/pkg/send/email.go @@ -0,0 +1,65 @@ +package send + +import ( + "os" + "time" + + "github.com/pPrecel/PKUP/pkg/config" + "github.com/pterm/pterm" +) + +type Sender interface { + ForConfig(*config.Config, string) error +} + +type sender struct { + logger *pterm.Logger +} + +func New(logger *pterm.Logger) Sender { + return &sender{ + logger: logger, + } +} + +func (s *sender) ForConfig(config *config.Config, zipSuffix string) error { + body := "" + if config.Send.HTMLBodyPath != "" { + s.logger.Debug("reading body", s.logger.Args("path", config.Send.HTMLBodyPath)) + bodyBytes, err := os.ReadFile(config.Send.HTMLBodyPath) + if err != nil { + return err + } + + body = string(bodyBytes) + } + + dialer, err := NewDialer(s.logger, config.Send.ServerAddress, config.Send.ServerPort, config.Send.Username, config.Send.Password) + if err != nil { + return err + } + defer dialer.Close() + + zipper := NewZipper(s.logger) + + for i, report := range config.Reports { + s.logger.Debug("zipping report", s.logger.Args("dir", report.OutputDir, "suffix", zipSuffix)) + reportFile, err := zipper.Do(report.OutputDir, zipSuffix) + if err != nil { + return err + } + + s.logger.Info("sending report", s.logger.Args("from", config.Send.From, "to", report.Email, "attachmentPath", reportFile)) + err = dialer.SendMail(config.Send.From, config.Send.Subject, report.Email, body, reportFile) + if err != nil { + return err + } + + if config.Send.Delay != nil && i < len(config.Reports)-1 { + s.logger.Debug("waiting...", s.logger.Args("delay", config.Send.Delay.String())) + time.Sleep(*config.Send.Delay) + } + } + + return nil +} diff --git a/pkg/send/zip.go b/pkg/send/zip.go new file mode 100644 index 0000000..9e201ee --- /dev/null +++ b/pkg/send/zip.go @@ -0,0 +1,78 @@ +package send + +import ( + "archive/zip" + "fmt" + "os" + "path/filepath" + + "github.com/pterm/pterm" +) + +type zipper struct { + logger *pterm.Logger +} + +func NewZipper(logger *pterm.Logger) *zipper { + return &zipper{ + logger: logger, + } +} + +func (z *zipper) Do(zipFilename, zipSuffix string) (string, error) { + fullZipPath := fmt.Sprintf("%s%s.%s", zipFilename, zipSuffix, "zip") + + z.logger.Debug("creating zip file", z.logger.Args( + "path", fullZipPath, + )) + outFile, err := os.Create(fullZipPath) + if err != nil { + return "", err + } + defer outFile.Close() + + w := zip.NewWriter(outFile) + defer w.Close() + + baseInZip := fmt.Sprintf("%s%s", filepath.Base(zipFilename), zipSuffix) + if err := z.addFilesToZip(w, zipFilename, baseInZip); err != nil { + return "", err + } + + return fullZipPath, nil +} + +func (z *zipper) addFilesToZip(w *zip.Writer, basePath, baseInZip string) error { + files, err := os.ReadDir(basePath) + if err != nil { + return err + } + + for _, file := range files { + fullFilepath := filepath.Join(basePath, file.Name()) + z.logger.Trace("adding item to zip", z.logger.Args( + "path", fullFilepath, + "baseInZip", baseInZip, + )) + + if file.IsDir() { + if err := z.addFilesToZip(w, fullFilepath, filepath.Join(baseInZip, file.Name())); err != nil { + return err + } + } else if file.Type().IsRegular() { + dat, err := os.ReadFile(fullFilepath) + if err != nil { + return err + } + f, err := w.Create(filepath.Join(baseInZip, file.Name())) + if err != nil { + return err + } + _, err = f.Write(dat) + if err != nil { + return err + } + } + } + return nil +}