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:
2
main.go
2
main.go
@ -160,7 +160,7 @@ func main() {
|
|||||||
|
|
||||||
// MVC Handlers
|
// MVC Handlers
|
||||||
summaryHandler := routes.NewSummaryHandler(summaryService, userService)
|
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)
|
homeHandler := routes.NewHomeHandler(keyValueService)
|
||||||
loginHandler := routes.NewLoginHandler(userService, mailService)
|
loginHandler := routes.NewLoginHandler(userService, mailService)
|
||||||
imprintHandler := routes.NewImprintHandler(keyValueService)
|
imprintHandler := routes.NewImprintHandler(keyValueService)
|
||||||
|
@ -55,3 +55,12 @@ func (m MailAddresses) RawStrings() []string {
|
|||||||
}
|
}
|
||||||
return out
|
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 {
|
} else {
|
||||||
go func(user *models.User) {
|
go func(user *models.User) {
|
||||||
link := fmt.Sprintf("%s/set-password?token=%s", h.config.Server.GetPublicUrl(), user.ResetToken)
|
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)
|
logbuch.Error("failed to send password reset mail to %s – %v", user.ID, err)
|
||||||
} else {
|
} else {
|
||||||
logbuch.Info("sent password reset mail to %s", user.ID)
|
logbuch.Info("sent password reset mail to %s", user.ID)
|
||||||
|
@ -27,6 +27,7 @@ type SettingsHandler struct {
|
|||||||
aggregationSrvc services.IAggregationService
|
aggregationSrvc services.IAggregationService
|
||||||
languageMappingSrvc services.ILanguageMappingService
|
languageMappingSrvc services.ILanguageMappingService
|
||||||
keyValueSrvc services.IKeyValueService
|
keyValueSrvc services.IKeyValueService
|
||||||
|
mailSrvc services.IMailService
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -40,6 +41,7 @@ func NewSettingsHandler(
|
|||||||
aggregationService services.IAggregationService,
|
aggregationService services.IAggregationService,
|
||||||
languageMappingService services.ILanguageMappingService,
|
languageMappingService services.ILanguageMappingService,
|
||||||
keyValueService services.IKeyValueService,
|
keyValueService services.IKeyValueService,
|
||||||
|
mailService services.IMailService,
|
||||||
) *SettingsHandler {
|
) *SettingsHandler {
|
||||||
return &SettingsHandler{
|
return &SettingsHandler{
|
||||||
config: conf.Get(),
|
config: conf.Get(),
|
||||||
@ -50,6 +52,7 @@ func NewSettingsHandler(
|
|||||||
userSrvc: userService,
|
userSrvc: userService,
|
||||||
heartbeatSrvc: heartbeatService,
|
heartbeatSrvc: heartbeatService,
|
||||||
keyValueSrvc: keyValueService,
|
keyValueSrvc: keyValueService,
|
||||||
|
mailSrvc: mailService,
|
||||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
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) {
|
go func(user *models.User) {
|
||||||
|
start := time.Now()
|
||||||
importer := imports.NewWakatimeHeartbeatImporter(user.WakatimeApiKey)
|
importer := imports.NewWakatimeHeartbeatImporter(user.WakatimeApiKey)
|
||||||
|
|
||||||
countBefore, err := h.heartbeatSrvc.CountByUser(user)
|
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)
|
logbuch.Info("downloaded %d heartbeats for user '%s' (%d actually imported)", count, user.ID, countAfter-countBefore)
|
||||||
|
|
||||||
h.regenerateSummaries(user)
|
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)
|
}(user)
|
||||||
|
|
||||||
h.keyValueSrvc.PutString(&models.KeyStringValue{
|
h.keyValueSrvc.PutString(&models.KeyStringValue{
|
||||||
|
@ -11,29 +11,37 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
tplPath = "/views/mail"
|
tplPath = "/views/mail"
|
||||||
tplNamePasswordReset = "reset_password"
|
tplNamePasswordReset = "reset_password"
|
||||||
subjectPasswordReset = "Wakapi – Password Reset"
|
tplNameImportNotification = "import_finished"
|
||||||
|
subjectPasswordReset = "Wakapi – Password Reset"
|
||||||
|
subjectImportNotification = "Wakapi – Data Import Finished"
|
||||||
)
|
)
|
||||||
|
|
||||||
type passwordResetLinkTplData struct {
|
type PasswordResetTplData struct {
|
||||||
ResetLink string
|
ResetLink string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ImportNotificationTplData struct {
|
||||||
|
PublicUrl string
|
||||||
|
Duration string
|
||||||
|
NumHeartbeats int
|
||||||
|
}
|
||||||
|
|
||||||
// Factory
|
// Factory
|
||||||
func NewMailService() services.IMailService {
|
func NewMailService() services.IMailService {
|
||||||
config := conf.Get()
|
config := conf.Get()
|
||||||
if config.Mail.Enabled {
|
if config.Mail.Enabled {
|
||||||
if config.Mail.Provider == conf.MailProviderMailWhale {
|
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 {
|
} else if config.Mail.Provider == conf.MailProviderSmtp {
|
||||||
return NewSMTPMailService(config.Mail.Smtp)
|
return NewSMTPMailService(config.Mail.Smtp, config.Server.PublicUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return &NoopMailService{}
|
return &NoopMailService{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getPasswordResetTemplate(data passwordResetLinkTplData) (*bytes.Buffer, error) {
|
func getPasswordResetTemplate(data PasswordResetTplData) (*bytes.Buffer, error) {
|
||||||
tpl, err := loadTemplate(tplNamePasswordReset)
|
tpl, err := loadTemplate(tplNamePasswordReset)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -45,6 +53,18 @@ func getPasswordResetTemplate(data passwordResetLinkTplData) (*bytes.Buffer, err
|
|||||||
return &rendered, nil
|
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) {
|
func loadTemplate(tplName string) (*template.Template, error) {
|
||||||
tplFile, err := pkger.Open(fmt.Sprintf("%s/%s.tpl.html", tplPath, tplName))
|
tplFile, err := pkger.Open(fmt.Sprintf("%s/%s.tpl.html", tplPath, tplName))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -12,6 +12,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type MailWhaleMailService struct {
|
type MailWhaleMailService struct {
|
||||||
|
publicUrl string
|
||||||
config *conf.MailwhaleMailConfig
|
config *conf.MailwhaleMailConfig
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
}
|
}
|
||||||
@ -25,26 +26,39 @@ type MailWhaleSendRequest struct {
|
|||||||
TemplateVars map[string]string `json:"template_vars"`
|
TemplateVars map[string]string `json:"template_vars"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMailWhaleService(config *conf.MailwhaleMailConfig) *MailWhaleMailService {
|
func NewMailWhaleService(config *conf.MailwhaleMailConfig, publicUrl string) *MailWhaleMailService {
|
||||||
return &MailWhaleMailService{
|
return &MailWhaleMailService{
|
||||||
config: config,
|
publicUrl: publicUrl,
|
||||||
|
config: config,
|
||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
Timeout: 10 * time.Second,
|
Timeout: 10 * time.Second,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MailWhaleMailService) SendPasswordResetMail(recipient *models.User, resetLink string) error {
|
func (s *MailWhaleMailService) SendPasswordReset(recipient *models.User, resetLink string) error {
|
||||||
template, err := getPasswordResetTemplate(passwordResetLinkTplData{ResetLink: resetLink})
|
template, err := getPasswordResetTemplate(PasswordResetTplData{ResetLink: resetLink})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return s.send(recipient.Email, subjectPasswordReset, template.String(), true)
|
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 {
|
func (s *MailWhaleMailService) send(to, subject, body string, isHtml bool) error {
|
||||||
if to == "" {
|
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{
|
sendRequest := &MailWhaleSendRequest{
|
||||||
|
@ -3,11 +3,17 @@ package mail
|
|||||||
import (
|
import (
|
||||||
"github.com/emvi/logbuch"
|
"github.com/emvi/logbuch"
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type NoopMailService struct{}
|
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)
|
logbuch.Info("noop mail service doing nothing instead of sending password reset mail to %s", recipient.ID)
|
||||||
return nil
|
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 (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"github.com/emersion/go-sasl"
|
"github.com/emersion/go-sasl"
|
||||||
"github.com/emersion/go-smtp"
|
"github.com/emersion/go-smtp"
|
||||||
conf "github.com/muety/wakapi/config"
|
conf "github.com/muety/wakapi/config"
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
"io"
|
"io"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SMTPMailService struct {
|
type SMTPMailService struct {
|
||||||
config *conf.SMTPMailConfig
|
publicUrl string
|
||||||
auth sasl.Client
|
config *conf.SMTPMailConfig
|
||||||
|
auth sasl.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSMTPMailService(config *conf.SMTPMailConfig) *SMTPMailService {
|
func NewSMTPMailService(config *conf.SMTPMailConfig, publicUrl string) *SMTPMailService {
|
||||||
return &SMTPMailService{
|
return &SMTPMailService{
|
||||||
config: config,
|
publicUrl: publicUrl,
|
||||||
|
config: config,
|
||||||
auth: sasl.NewPlainClient(
|
auth: sasl.NewPlainClient(
|
||||||
"",
|
"",
|
||||||
config.Username,
|
config.Username,
|
||||||
@ -25,8 +29,8 @@ func NewSMTPMailService(config *conf.SMTPMailConfig) *SMTPMailService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SMTPMailService) SendPasswordResetMail(recipient *models.User, resetLink string) error {
|
func (s *SMTPMailService) SendPasswordReset(recipient *models.User, resetLink string) error {
|
||||||
template, err := getPasswordResetTemplate(passwordResetLinkTplData{ResetLink: resetLink})
|
template, err := getPasswordResetTemplate(PasswordResetTplData{ResetLink: resetLink})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -38,14 +42,27 @@ func (s *SMTPMailService) SendPasswordResetMail(recipient *models.User, resetLin
|
|||||||
}
|
}
|
||||||
mail.WithHTML(template.String())
|
mail.WithHTML(template.String())
|
||||||
|
|
||||||
return s.send(
|
return s.send(s.config.ConnStr(), s.config.TLS, s.auth, mail.From.Raw(), mail.To.RawStrings(), mail.Reader())
|
||||||
s.config.ConnStr(),
|
}
|
||||||
s.config.TLS,
|
|
||||||
s.auth,
|
func (s *SMTPMailService) SendImportNotification(recipient *models.User, duration time.Duration, numHeartbeats int) error {
|
||||||
mail.From.Raw(),
|
template, err := getImportNotificationTemplate(ImportNotificationTplData{
|
||||||
mail.To.RawStrings(),
|
PublicUrl: s.publicUrl,
|
||||||
mail.Reader(),
|
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 {
|
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 {
|
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>
|
<head>
|
||||||
<meta name="viewport" content="width=device-width">
|
<meta name="viewport" content="width=device-width">
|
||||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||||
<title>Simple Transactional Email</title>
|
<title>Wakapi – Reset Password</title>
|
||||||
<style>
|
<style>
|
||||||
@media only screen and (max-width: 620px) {
|
@media only screen and (max-width: 620px) {
|
||||||
table[class=body] h1 {
|
table[class=body] h1 {
|
||||||
@ -89,7 +89,6 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</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%;">
|
<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;">
|
<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>
|
<tr>
|
||||||
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;"> </td>
|
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;"> </td>
|
||||||
|
Reference in New Issue
Block a user