mirror of
https://github.com/muety/wakapi.git
synced 2023-08-10 21:12:56 +03:00
chore: send notification on successful import
This commit is contained in:
parent
56247b4e1e
commit
2a9fbfdfd7
2
main.go
2
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)
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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{
|
||||
|
@ -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 {
|
||||
|
@ -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{
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
}
|
||||
|
152
views/mail/import_finished.tpl.html
Normal file
152
views/mail/import_finished.tpl.html
Normal file
@ -0,0 +1,152 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<title>Wakapi – Data Import Finished</title>
|
||||
<style>
|
||||
@media only screen and (max-width: 620px) {
|
||||
table[class=body] h1 {
|
||||
font-size: 28px !important;
|
||||
margin-bottom: 10px !important;
|
||||
}
|
||||
table[class=body] p,
|
||||
table[class=body] ul,
|
||||
table[class=body] ol,
|
||||
table[class=body] td,
|
||||
table[class=body] span,
|
||||
table[class=body] a {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
table[class=body] .wrapper,
|
||||
table[class=body] .article {
|
||||
padding: 10px !important;
|
||||
}
|
||||
table[class=body] .content {
|
||||
padding: 0 !important;
|
||||
}
|
||||
table[class=body] .container {
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
table[class=body] .main {
|
||||
border-left-width: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
border-right-width: 0 !important;
|
||||
}
|
||||
table[class=body] .btn table {
|
||||
width: 100% !important;
|
||||
}
|
||||
table[class=body] .btn a {
|
||||
width: 100% !important;
|
||||
}
|
||||
table[class=body] .img-responsive {
|
||||
height: auto !important;
|
||||
max-width: 100% !important;
|
||||
width: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------
|
||||
PRESERVE THESE STYLES IN THE HEAD
|
||||
------------------------------------- */
|
||||
@media all {
|
||||
.ExternalClass {
|
||||
width: 100%;
|
||||
}
|
||||
.ExternalClass,
|
||||
.ExternalClass p,
|
||||
.ExternalClass span,
|
||||
.ExternalClass font,
|
||||
.ExternalClass td,
|
||||
.ExternalClass div {
|
||||
line-height: 100%;
|
||||
}
|
||||
.apple-link a {
|
||||
color: inherit !important;
|
||||
font-family: inherit !important;
|
||||
font-size: inherit !important;
|
||||
font-weight: inherit !important;
|
||||
line-height: inherit !important;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
#MessageViewBody a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
font-weight: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
.btn-primary table td:hover {
|
||||
background-color: #047857 !important;
|
||||
}
|
||||
.btn-primary a:hover {
|
||||
background-color: #047857 !important;
|
||||
border-color: #047857 !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="" style="background-color: #f6f6f6; font-family: sans-serif; -webkit-font-smoothing: antialiased; font-size: 14px; line-height: 1.4; margin: 0; padding: 0; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="body" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background-color: #f6f6f6;">
|
||||
<tr>
|
||||
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;"> </td>
|
||||
<td class="container" style="font-family: sans-serif; font-size: 14px; vertical-align: top; display: block; Margin: 0 auto; max-width: 580px; padding: 10px; width: 580px;">
|
||||
<div class="content" style="box-sizing: border-box; display: block; Margin: 0 auto; max-width: 580px; padding: 10px;">
|
||||
|
||||
<!-- START CENTERED WHITE CONTAINER -->
|
||||
<table class="main" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background: #ffffff; border-radius: 3px;">
|
||||
|
||||
<!-- START MAIN CONTENT AREA -->
|
||||
<tr>
|
||||
<td class="wrapper" style="font-family: sans-serif; font-size: 14px; vertical-align: top; box-sizing: border-box; padding: 20px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
|
||||
<tr>
|
||||
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;">
|
||||
<p style="font-family: sans-serif; font-size: 18px; font-weight: 500; margin: 0; Margin-bottom: 15px;">Data import finished</p>
|
||||
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; Margin-bottom: 15px;">You have requested to import data from WakaTime to Wakapi. The import has now finished after {{ .Duration }} ({{ .NumHeartbeats }} new heartbeats imported).<br><br>You should be able to see the newly imported coding statistics in Wakapi.</p>
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="btn btn-primary" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; box-sizing: border-box;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="font-family: sans-serif; font-size: 14px; vertical-align: top; padding-bottom: 15px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: auto;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top; background-color: #2F855A; border-radius: 5px; text-align: center;"> <a href="{{ .PublicUrl }}" target="_blank" style="display: inline-block; color: #ffffff; background-color: #2F855A; border: solid 1px #2F855A; border-radius: 5px; box-sizing: border-box; cursor: pointer; text-decoration: none; font-size: 14px; font-weight: bold; margin: 0; padding: 12px 25px; text-transform: capitalize; border-color: #2F855A;">Go to dashboard</a> </td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- END MAIN CONTENT AREA -->
|
||||
</table>
|
||||
|
||||
<!-- START FOOTER -->
|
||||
<div class="footer" style="clear: both; Margin-top: 10px; text-align: center; width: 100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
|
||||
<tr>
|
||||
<td class="content-block powered-by" style="font-family: sans-serif; vertical-align: top; padding-bottom: 10px; padding-top: 10px; font-size: 12px; color: #999999; text-align: center;">
|
||||
Powered by <a href="https://wakapi.dev" style="color: #999999; font-size: 12px; text-align: center; text-decoration: none;">Wakapi.dev</a>.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!-- END FOOTER -->
|
||||
|
||||
<!-- END CENTERED WHITE CONTAINER -->
|
||||
</div>
|
||||
</td>
|
||||
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;"> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<title>Simple Transactional Email</title>
|
||||
<title>Wakapi – Reset Password</title>
|
||||
<style>
|
||||
@media only screen and (max-width: 620px) {
|
||||
table[class=body] h1 {
|
||||
@ -89,7 +89,6 @@
|
||||
</style>
|
||||
</head>
|
||||
<body class="" style="background-color: #f6f6f6; font-family: sans-serif; -webkit-font-smoothing: antialiased; font-size: 14px; line-height: 1.4; margin: 0; padding: 0; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;">
|
||||
<span class="preheader" style="color: transparent; display: none; height: 0; max-height: 0; max-width: 0; opacity: 0; overflow: hidden; mso-hide: all; visibility: hidden; width: 0;">This is preheader text. Some clients will show this text as a preview.</span>
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="body" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background-color: #f6f6f6;">
|
||||
<tr>
|
||||
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;"> </td>
|
||||
|
Loading…
Reference in New Issue
Block a user