diff --git a/main.go b/main.go index dfb478e..19a5371 100644 --- a/main.go +++ b/main.go @@ -160,7 +160,7 @@ func main() { // MVC Handlers summaryHandler := routes.NewSummaryHandler(summaryService, userService) - settingsHandler := routes.NewSettingsHandler(userService, heartbeatService, summaryService, aliasService, aggregationService, languageMappingService, keyValueService) + settingsHandler := routes.NewSettingsHandler(userService, heartbeatService, summaryService, aliasService, aggregationService, languageMappingService, keyValueService, mailService) homeHandler := routes.NewHomeHandler(keyValueService) loginHandler := routes.NewLoginHandler(userService, mailService) imprintHandler := routes.NewImprintHandler(keyValueService) diff --git a/models/mail_address.go b/models/mail_address.go index 726a680..324f63e 100644 --- a/models/mail_address.go +++ b/models/mail_address.go @@ -55,3 +55,12 @@ func (m MailAddresses) RawStrings() []string { } return out } + +func (m MailAddresses) AllValid() bool { + for _, a := range m { + if !a.Valid() { + return false + } + } + return true +} diff --git a/routes/login.go b/routes/login.go index 166f606..b5c2900 100644 --- a/routes/login.go +++ b/routes/login.go @@ -283,7 +283,7 @@ func (h *LoginHandler) PostResetPassword(w http.ResponseWriter, r *http.Request) } else { go func(user *models.User) { link := fmt.Sprintf("%s/set-password?token=%s", h.config.Server.GetPublicUrl(), user.ResetToken) - if err := h.mailSrvc.SendPasswordResetMail(user, link); err != nil { + if err := h.mailSrvc.SendPasswordReset(user, link); err != nil { logbuch.Error("failed to send password reset mail to %s – %v", user.ID, err) } else { logbuch.Info("sent password reset mail to %s", user.ID) diff --git a/routes/settings.go b/routes/settings.go index 61eb61e..8297f65 100644 --- a/routes/settings.go +++ b/routes/settings.go @@ -27,6 +27,7 @@ type SettingsHandler struct { aggregationSrvc services.IAggregationService languageMappingSrvc services.ILanguageMappingService keyValueSrvc services.IKeyValueService + mailSrvc services.IMailService httpClient *http.Client } @@ -40,6 +41,7 @@ func NewSettingsHandler( aggregationService services.IAggregationService, languageMappingService services.ILanguageMappingService, keyValueService services.IKeyValueService, + mailService services.IMailService, ) *SettingsHandler { return &SettingsHandler{ config: conf.Get(), @@ -50,6 +52,7 @@ func NewSettingsHandler( userSrvc: userService, heartbeatSrvc: heartbeatService, keyValueSrvc: keyValueService, + mailSrvc: mailService, httpClient: &http.Client{Timeout: 10 * time.Second}, } } @@ -401,6 +404,7 @@ func (h *SettingsHandler) actionImportWaktime(w http.ResponseWriter, r *http.Req } go func(user *models.User) { + start := time.Now() importer := imports.NewWakatimeHeartbeatImporter(user.WakatimeApiKey) countBefore, err := h.heartbeatSrvc.CountByUser(user) @@ -443,6 +447,14 @@ func (h *SettingsHandler) actionImportWaktime(w http.ResponseWriter, r *http.Req logbuch.Info("downloaded %d heartbeats for user '%s' (%d actually imported)", count, user.ID, countAfter-countBefore) h.regenerateSummaries(user) + + if user.Email != "" { + if err := h.mailSrvc.SendImportNotification(user, time.Now().Sub(start), int(countAfter-countBefore)); err != nil { + logbuch.Error("failed to send import notification mail to %s – %v", user.ID, err) + } else { + logbuch.Info("sent import notification mail to %s", user.ID) + } + } }(user) h.keyValueSrvc.PutString(&models.KeyStringValue{ diff --git a/services/mail/mail.go b/services/mail/mail.go index 5aeb07c..b94ce68 100644 --- a/services/mail/mail.go +++ b/services/mail/mail.go @@ -11,29 +11,37 @@ import ( ) const ( - tplPath = "/views/mail" - tplNamePasswordReset = "reset_password" - subjectPasswordReset = "Wakapi – Password Reset" + tplPath = "/views/mail" + tplNamePasswordReset = "reset_password" + tplNameImportNotification = "import_finished" + subjectPasswordReset = "Wakapi – Password Reset" + subjectImportNotification = "Wakapi – Data Import Finished" ) -type passwordResetLinkTplData struct { +type PasswordResetTplData struct { ResetLink string } +type ImportNotificationTplData struct { + PublicUrl string + Duration string + NumHeartbeats int +} + // Factory func NewMailService() services.IMailService { config := conf.Get() if config.Mail.Enabled { if config.Mail.Provider == conf.MailProviderMailWhale { - return NewMailWhaleService(config.Mail.MailWhale) + return NewMailWhaleService(config.Mail.MailWhale, config.Server.PublicUrl) } else if config.Mail.Provider == conf.MailProviderSmtp { - return NewSMTPMailService(config.Mail.Smtp) + return NewSMTPMailService(config.Mail.Smtp, config.Server.PublicUrl) } } return &NoopMailService{} } -func getPasswordResetTemplate(data passwordResetLinkTplData) (*bytes.Buffer, error) { +func getPasswordResetTemplate(data PasswordResetTplData) (*bytes.Buffer, error) { tpl, err := loadTemplate(tplNamePasswordReset) if err != nil { return nil, err @@ -45,6 +53,18 @@ func getPasswordResetTemplate(data passwordResetLinkTplData) (*bytes.Buffer, err return &rendered, nil } +func getImportNotificationTemplate(data ImportNotificationTplData) (*bytes.Buffer, error) { + tpl, err := loadTemplate(tplNameImportNotification) + if err != nil { + return nil, err + } + var rendered bytes.Buffer + if err := tpl.Execute(&rendered, data); err != nil { + return nil, err + } + return &rendered, nil +} + func loadTemplate(tplName string) (*template.Template, error) { tplFile, err := pkger.Open(fmt.Sprintf("%s/%s.tpl.html", tplPath, tplName)) if err != nil { diff --git a/services/mail/mailwhale.go b/services/mail/mailwhale.go index bae7986..d7cc164 100644 --- a/services/mail/mailwhale.go +++ b/services/mail/mailwhale.go @@ -12,6 +12,7 @@ import ( ) type MailWhaleMailService struct { + publicUrl string config *conf.MailwhaleMailConfig httpClient *http.Client } @@ -25,26 +26,39 @@ type MailWhaleSendRequest struct { TemplateVars map[string]string `json:"template_vars"` } -func NewMailWhaleService(config *conf.MailwhaleMailConfig) *MailWhaleMailService { +func NewMailWhaleService(config *conf.MailwhaleMailConfig, publicUrl string) *MailWhaleMailService { return &MailWhaleMailService{ - config: config, + publicUrl: publicUrl, + config: config, httpClient: &http.Client{ Timeout: 10 * time.Second, }, } } -func (s *MailWhaleMailService) SendPasswordResetMail(recipient *models.User, resetLink string) error { - template, err := getPasswordResetTemplate(passwordResetLinkTplData{ResetLink: resetLink}) +func (s *MailWhaleMailService) SendPasswordReset(recipient *models.User, resetLink string) error { + template, err := getPasswordResetTemplate(PasswordResetTplData{ResetLink: resetLink}) if err != nil { return err } return s.send(recipient.Email, subjectPasswordReset, template.String(), true) } +func (s *MailWhaleMailService) SendImportNotification(recipient *models.User, duration time.Duration, numHeartbeats int) error { + template, err := getImportNotificationTemplate(ImportNotificationTplData{ + PublicUrl: s.publicUrl, + Duration: fmt.Sprintf("%.0f seconds", duration.Seconds()), + NumHeartbeats: numHeartbeats, + }) + if err != nil { + return err + } + return s.send(recipient.Email, subjectImportNotification, template.String(), true) +} + func (s *MailWhaleMailService) send(to, subject, body string, isHtml bool) error { if to == "" { - return errors.New("no recipient mail address set, cannot send password reset link") + return errors.New("not sending mail as recipient mail address seems to be invalid") } sendRequest := &MailWhaleSendRequest{ diff --git a/services/mail/noop.go b/services/mail/noop.go index a1ef569..c26ae9f 100644 --- a/services/mail/noop.go +++ b/services/mail/noop.go @@ -3,11 +3,17 @@ package mail import ( "github.com/emvi/logbuch" "github.com/muety/wakapi/models" + "time" ) type NoopMailService struct{} -func (n *NoopMailService) SendPasswordResetMail(recipient *models.User, resetLink string) error { +func (n *NoopMailService) SendPasswordReset(recipient *models.User, resetLink string) error { logbuch.Info("noop mail service doing nothing instead of sending password reset mail to %s", recipient.ID) return nil } + +func (n *NoopMailService) SendImportNotification(recipient *models.User, duration time.Duration, numHeartbeats int) error { + logbuch.Info("noop mail service doing nothing instead of sending import notification mail to %s", recipient.ID) + return nil +} diff --git a/services/mail/smtp.go b/services/mail/smtp.go index cf15b3e..d040f51 100644 --- a/services/mail/smtp.go +++ b/services/mail/smtp.go @@ -2,21 +2,25 @@ package mail import ( "errors" + "fmt" "github.com/emersion/go-sasl" "github.com/emersion/go-smtp" conf "github.com/muety/wakapi/config" "github.com/muety/wakapi/models" "io" + "time" ) type SMTPMailService struct { - config *conf.SMTPMailConfig - auth sasl.Client + publicUrl string + config *conf.SMTPMailConfig + auth sasl.Client } -func NewSMTPMailService(config *conf.SMTPMailConfig) *SMTPMailService { +func NewSMTPMailService(config *conf.SMTPMailConfig, publicUrl string) *SMTPMailService { return &SMTPMailService{ - config: config, + publicUrl: publicUrl, + config: config, auth: sasl.NewPlainClient( "", config.Username, @@ -25,8 +29,8 @@ func NewSMTPMailService(config *conf.SMTPMailConfig) *SMTPMailService { } } -func (s *SMTPMailService) SendPasswordResetMail(recipient *models.User, resetLink string) error { - template, err := getPasswordResetTemplate(passwordResetLinkTplData{ResetLink: resetLink}) +func (s *SMTPMailService) SendPasswordReset(recipient *models.User, resetLink string) error { + template, err := getPasswordResetTemplate(PasswordResetTplData{ResetLink: resetLink}) if err != nil { return err } @@ -38,14 +42,27 @@ func (s *SMTPMailService) SendPasswordResetMail(recipient *models.User, resetLin } mail.WithHTML(template.String()) - return s.send( - s.config.ConnStr(), - s.config.TLS, - s.auth, - mail.From.Raw(), - mail.To.RawStrings(), - mail.Reader(), - ) + return s.send(s.config.ConnStr(), s.config.TLS, s.auth, mail.From.Raw(), mail.To.RawStrings(), mail.Reader()) +} + +func (s *SMTPMailService) SendImportNotification(recipient *models.User, duration time.Duration, numHeartbeats int) error { + template, err := getImportNotificationTemplate(ImportNotificationTplData{ + PublicUrl: s.publicUrl, + Duration: fmt.Sprintf("%.0f seconds", duration.Seconds()), + NumHeartbeats: numHeartbeats, + }) + if err != nil { + return err + } + + mail := &models.Mail{ + From: models.MailAddress(s.config.Sender), + To: models.MailAddresses([]models.MailAddress{models.MailAddress(recipient.Email)}), + Subject: subjectImportNotification, + } + mail.WithHTML(template.String()) + + return s.send(s.config.ConnStr(), s.config.TLS, s.auth, mail.From.Raw(), mail.To.RawStrings(), mail.Reader()) } func (s *SMTPMailService) send(addr string, tls bool, a sasl.Client, from string, to []string, r io.Reader) error { diff --git a/services/services.go b/services/services.go index 0242cd7..43c9d24 100644 --- a/services/services.go +++ b/services/services.go @@ -80,5 +80,6 @@ type IUserService interface { } type IMailService interface { - SendPasswordResetMail(recipient *models.User, resetLink string) error + SendPasswordReset(*models.User, string) error + SendImportNotification(*models.User, time.Duration, int) error } diff --git a/views/mail/import_finished.tpl.html b/views/mail/import_finished.tpl.html new file mode 100644 index 0000000..6e68e3d --- /dev/null +++ b/views/mail/import_finished.tpl.html @@ -0,0 +1,152 @@ + + + + + + Wakapi – Data Import Finished + + + + + + + + + +
  +
+ + + + + + + + + + +
+ + + + +
+

Data import finished

+

You have requested to import data from WakaTime to Wakapi. The import has now finished after {{ .Duration }} ({{ .NumHeartbeats }} new heartbeats imported).

You should be able to see the newly imported coding statistics in Wakapi.

+ + + + + + +
+ + + + + + +
Go to dashboard
+
+
+
+ + + + + + +
+
 
+ + diff --git a/views/mail/reset_password.tpl.html b/views/mail/reset_password.tpl.html index 982baa7..c01cf1a 100644 --- a/views/mail/reset_password.tpl.html +++ b/views/mail/reset_password.tpl.html @@ -3,7 +3,7 @@ - Simple Transactional Email + Wakapi – Reset Password -