Add API methods for setting and deleting email (#483)
* add API methods for setting and deleting email * fix service.SetStringUserDetail signature to return string * switch table test with description to t.Run() * remove debug logging * clarify error handling, functions names * add email integration test * add information about email subscription to readme * change email API calls method from PUT to POST * typo fix, remove unneeded capturing of range variable * email test draft * fix notify mock, email notification test draft * add MockDestination to startupT return * fix tests * add email retrieval for notifications sending * fix mock for notify * rearrange mock notify declaration * add GET /email API handler, fix typos * revert startupT signature change * get rid of startupTWithDest workaround * add rest examples for rest notification * improve email messages formatting * fix email send repeater location * remove unneeded context from sendMessage * change signatures of buildMessage functions to have same field name * add missing authenticate call on TLS connection * add dev user auth token to email requests * change email verification template * email code and tests cleanup * replace fixed spaces with normal ones * human-readable variables names for new comment reply notification * rename Comment to CommentText * add html for comment email notification * fix comment notification html style * fix email test * fix notify email messages rendering * fix comments on rest examples for email * explicitly state email notify email template fields * clarify email API documentation * change email test not to check quoted-printable part of message * Fix link color, add unsubscribe link * fix rest examples tokens * add UnsubscribeLink support to Email * add unsubscribe email handler * fix new reply notification email style
This commit is contained in:
committed by
Umputun
parent
45fa60f5a1
commit
d23d119d70
@@ -16,7 +16,7 @@ Remark42 is a self-hosted, lightweight, and simple (yet functional) comment engi
|
||||
* Images upload with drag-and-drop
|
||||
* Extractor for recent comments, cross-post
|
||||
* RSS for all comments and each post
|
||||
* Telegram notifications
|
||||
* Telegram and email notifications
|
||||
* Export data to json with automatic backups
|
||||
* No external databases, everything embedded in a single data file
|
||||
* Fully dockerized and can be deployed in a single command
|
||||
@@ -154,11 +154,19 @@ _this is the recommended way to run remark42_
|
||||
| auth.email.passwd | AUTH_EMAIL_PASSWD | | smtp password |
|
||||
| auth.email.timeout | AUTH_EMAIL_TIMEOUT | `10s` | smtp timeout |
|
||||
| auth.email.template | AUTH_EMAIL_TEMPLATE | none (predefined) | custom email message template file |
|
||||
| notify.type | NOTIFY_TYPE | none | type of notification (none or telegram) |
|
||||
| notify.type | NOTIFY_TYPE | none | type of notification (telegram and/or email) |
|
||||
| notify.queue | NOTIFY_QUEUE | `100` | size of notification queue |
|
||||
| notify.telegram.token | NOTIFY_TELEGRAM_TOKEN | | telegram token |
|
||||
| notify.telegram.chan | NOTIFY_TELEGRAM_CHAN | | telegram channel |
|
||||
| notify.telegram.timeout | NOTIFY_TELEGRAM_TIMEOUT | `5s` | telegram timeout |
|
||||
| notify.email.host | NOTIFY_EMAIL_HOST | | SMTP host |
|
||||
| notify.email.port | NOTIFY_EMAIL_PORT | `587` | SMTP port |
|
||||
| notify.email.tls | NOTIFY_EMAIL_TLS | | enable TLS for SMTP |
|
||||
| notify.email.fromAddress | NOTIFY_EMAIL_FROM | | from email address |
|
||||
| notify.email.username | NOTIFY_EMAIL_USERNAME | | SMTP user name |
|
||||
| notify.email.password | NOTIFY_EMAIL_PASSWORD | | SMTP password |
|
||||
| notify.email.timeout | NOTIFY_EMAIL_TIMEOUT | `10s` | SMTP TCP connection timeout |
|
||||
| notify.email.verification_subj | NOTIFY_EMAIL_VERIFICATION_SUBJ | `Email verification` | verification message subject |
|
||||
| ssl.type | SSL_TYPE | none | `none`-http, `static`-https, `auto`-https + le |
|
||||
| ssl.port | SSL_PORT | `8443` | port for https server |
|
||||
| ssl.cert | SSL_CERT | | path to cert.pem file |
|
||||
@@ -764,6 +772,17 @@ data: {"url":"https://radio-t.com/blah1","count":9,"first_time":"2019-06-18T12:5
|
||||
|
||||
_returned id should be appended to load image url on caller side_
|
||||
|
||||
### Email subscription
|
||||
|
||||
* `GET /api/v1/email?site=site-id` - get user's email, _auth required_
|
||||
* `POST /api/v1/email/subscribe?site=site-id&address=user@example.org` - makes confirmation token and sends it to user over email, _auth required_
|
||||
|
||||
Trying to subscribe same email second time will return response code `409 Conflict` and explaining error message.
|
||||
* `POST /api/v1/email/confirm?site=site-id&tkn=token` - uses provided token parameter to set email for the user, _auth required_
|
||||
|
||||
Setting email subscribe user for all first-level replies to his messages.
|
||||
* `DELETE /api/v1/email?site=siteID` - removes user's email, _auth required_
|
||||
|
||||
### Admin
|
||||
|
||||
* `DELETE /api/v1/admin/comment/{id}?site=site-id&url=post-url` - delete comment by `id`.
|
||||
|
||||
+78
-17
@@ -15,6 +15,7 @@ import (
|
||||
"time"
|
||||
|
||||
bolt "github.com/coreos/bbolt"
|
||||
"github.com/dgrijalva/jwt-go"
|
||||
"github.com/go-pkgz/jrpc"
|
||||
log "github.com/go-pkgz/lgr"
|
||||
"github.com/kyokomi/emoji"
|
||||
@@ -168,14 +169,24 @@ type AdminGroup struct {
|
||||
|
||||
// NotifyGroup defines options for notification
|
||||
type NotifyGroup struct {
|
||||
Type string `long:"type" env:"TYPE" description:"type of notification" choice:"none" choice:"telegram" default:"none"` //nolint
|
||||
QueueSize int `long:"queue" env:"QUEUE" description:"size of notification queue" default:"100"`
|
||||
Type []string `long:"type" env:"TYPE" description:"type of notification" choice:"none" choice:"telegram" choice:"email" default:"none" env-delim:","` //nolint
|
||||
QueueSize int `long:"queue" env:"QUEUE" description:"size of notification queue" default:"100"`
|
||||
Telegram struct {
|
||||
Token string `long:"token" env:"TOKEN" description:"telegram token"`
|
||||
Channel string `long:"chan" env:"CHAN" description:"telegram channel"`
|
||||
Timeout time.Duration `long:"timeout" env:"TIMEOUT" default:"5s" description:"telegram timeout"`
|
||||
API string `long:"api" env:"API" default:"https://api.telegram.org/bot" description:"telegram api prefix"`
|
||||
} `group:"telegram" namespace:"telegram" env-namespace:"TELEGRAM"`
|
||||
Email struct {
|
||||
Host string `long:"host" env:"HOST" description:"SMTP host"`
|
||||
Port int `long:"port" env:"PORT" default:"587" description:"SMTP port"`
|
||||
TLS bool `long:"tls" env:"TLS" description:"enable TLS for SMTP"`
|
||||
From string `long:"fromAddress" env:"FROM" description:"from email address"`
|
||||
Username string `long:"username" env:"USERNAME" description:"SMTP user name"`
|
||||
Password string `long:"password" env:"PASSWORD" description:"SMTP password"`
|
||||
TimeOut time.Duration `long:"timeout" env:"TIMEOUT" default:"10s" description:"SMTP TCP connection timeout"`
|
||||
VerificationSubject string `long:"verification_subj" env:"VERIFICATION_SUBJ" description:"verification message subject"`
|
||||
} `group:"email" namespace:"email" env-namespace:"EMAIL"`
|
||||
}
|
||||
|
||||
// SSLGroup defines options group for server ssl params
|
||||
@@ -316,7 +327,7 @@ func (s *ServerCommand) newServerApp() (*serverApp, error) {
|
||||
KeyStore: adminStore,
|
||||
}
|
||||
|
||||
notifyService, err := s.makeNotify(dataService)
|
||||
notifyService, err := s.makeNotify(dataService, authenticator)
|
||||
if err != nil {
|
||||
log.Printf("[WARN] failed to make notify service, %s", err)
|
||||
notifyService = notify.NopService // disable notifier
|
||||
@@ -576,10 +587,10 @@ var msgTemplate = `
|
||||
<body>
|
||||
<div style="text-align: center; font-family: Arial, sans-serif; font-size: 18px;">
|
||||
<h1 style="position: relative; color: #4fbbd6; margin-top: 0.2em;">Remark42</h1>
|
||||
<p style="position: relative; max-width: 20em; margin: 0 auto 1em auto; line-height: 1.4em;">Confirmation for <b>{{.User}}</b> on site <b>{{.Site}}</b></p>
|
||||
<p style="position: relative; max-width: 20em; margin: 0 auto 1em auto; line-height: 1.4em;">Confirmation for <b>{{.User}}</b> on site <b>{{.Site}}</b></p>
|
||||
<div style="background-color: #eee; max-width: 20em; margin: 0 auto; border-radius: 0.4em; padding: 0.5em;">
|
||||
<p style="position: relative; margin: 0 0 0.5em 0;">TOKEN</p>
|
||||
<p style="position: relative; font-size: 0.7em; opacity: 0.8;"><i>Copy and paste this text into “token” field on comments page</i></p>
|
||||
<p style="position: relative; font-size: 0.7em; opacity: 0.8;"><i>Copy and paste this text into “token” field on comments page</i></p>
|
||||
<p style="position: relative; font-family: monospace; background-color: #fff; margin: 0; padding: 0.5em; word-break: break-all; text-align: left; border-radius: 0.2em; -webkit-user-select: all; user-select: all;">{{.Token}}</p>
|
||||
</div>
|
||||
<p style="position: relative; margin-top: 2em; font-size: 0.8em; opacity: 0.8;"><i>Sent to {{.Address}}</i></p>
|
||||
@@ -673,20 +684,65 @@ func (s *ServerCommand) loadEmailTemplate() string {
|
||||
return tmpl
|
||||
}
|
||||
|
||||
func (s *ServerCommand) makeNotify(dataStore *service.DataStore) (*notify.Service, error) {
|
||||
log.Printf("[INFO] make notify, type=%s", s.Notify.Type)
|
||||
switch s.Notify.Type {
|
||||
case "telegram":
|
||||
tg, err := notify.NewTelegram(s.Notify.Telegram.Token, s.Notify.Telegram.Channel,
|
||||
s.Notify.Telegram.Timeout, s.Notify.Telegram.API)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to create telegram notification destination")
|
||||
func (s *ServerCommand) makeNotify(dataStore *service.DataStore, authenticator *auth.Service) (*notify.Service, error) {
|
||||
var notifyService *notify.Service
|
||||
var destinations []notify.Destination
|
||||
for _, t := range s.Notify.Type {
|
||||
switch t {
|
||||
case "telegram":
|
||||
tg, err := notify.NewTelegram(s.Notify.Telegram.Token, s.Notify.Telegram.Channel,
|
||||
s.Notify.Telegram.Timeout, s.Notify.Telegram.API)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to create telegram notification destination")
|
||||
}
|
||||
destinations = append(destinations, tg)
|
||||
case "email":
|
||||
emailParams := notify.EmailParams{
|
||||
From: s.Notify.Email.From,
|
||||
VerificationSubject: s.Notify.Email.VerificationSubject,
|
||||
UnsubscribeURL: s.RemarkURL + "/api/v1/email/unsubscribe",
|
||||
TokenGenFn: func(userID, email, site string) (string, error) {
|
||||
claims := token.Claims{
|
||||
Handshake: &token.Handshake{ID: userID + "::" + email},
|
||||
StandardClaims: jwt.StandardClaims{
|
||||
Audience: site,
|
||||
ExpiresAt: time.Now().Add(100 * 365 * 24 * time.Hour).Unix(),
|
||||
NotBefore: time.Now().Add(-1 * time.Minute).Unix(),
|
||||
Issuer: "remark42",
|
||||
},
|
||||
}
|
||||
tkn, err := authenticator.TokenService().Token(claims)
|
||||
if err != nil {
|
||||
return "", errors.Wrapf(err, "failed to make unsubscription token")
|
||||
}
|
||||
return tkn, nil
|
||||
},
|
||||
}
|
||||
smtpParams := notify.SmtpParams{
|
||||
Host: s.Notify.Email.Host,
|
||||
Port: s.Notify.Email.Port,
|
||||
TLS: s.Notify.Email.TLS,
|
||||
Username: s.Notify.Email.Username,
|
||||
Password: s.Notify.Email.Password,
|
||||
TimeOut: s.Notify.Email.TimeOut,
|
||||
}
|
||||
emailService, err := notify.NewEmail(emailParams, smtpParams)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to create email notification destination")
|
||||
}
|
||||
destinations = append(destinations, emailService)
|
||||
case "none":
|
||||
notifyService = notify.NopService
|
||||
default:
|
||||
return nil, errors.Errorf("unsupported notification type %q", s.Notify.Type)
|
||||
}
|
||||
return notify.NewService(dataStore, s.Notify.QueueSize, tg), nil
|
||||
case "none":
|
||||
return notify.NopService, nil
|
||||
}
|
||||
return nil, errors.Errorf("unsupported notification type %q", s.Notify.Type)
|
||||
|
||||
if len(destinations) != 0 {
|
||||
log.Printf("[INFO] make notify, types=%s", s.Notify.Type)
|
||||
notifyService = notify.NewService(dataStore, s.Notify.QueueSize, destinations...)
|
||||
}
|
||||
return notifyService, nil
|
||||
}
|
||||
|
||||
func (s *ServerCommand) makeSSLConfig() (config api.SSLConfig, err error) {
|
||||
@@ -735,6 +791,11 @@ func (s *ServerCommand) makeAuthenticator(ds *service.DataStore, avas avatar.Sto
|
||||
}
|
||||
c.User.SetAdmin(ds.IsAdmin(c.Audience, c.User.ID))
|
||||
c.User.SetBoolAttr("blocked", ds.IsBlocked(c.Audience, c.User.ID))
|
||||
var err error
|
||||
c.User.Email, err = ds.GetUserEmail(store.Locator{SiteID: c.Audience}, c.User.ID)
|
||||
if err != nil {
|
||||
log.Printf("[WARN] can't read email for %s, %v", c.User.ID, err)
|
||||
}
|
||||
return c
|
||||
}),
|
||||
AdminPasswd: s.AdminPasswd,
|
||||
|
||||
@@ -469,9 +469,15 @@ func prepServerApp(t *testing.T, duration time.Duration, fn func(o ServerCommand
|
||||
cmd.Auth.Email.Enable = true
|
||||
cmd.Auth.Email.MsgTemplate = "testdata/email.tmpl"
|
||||
cmd.BackupLocation = "/tmp"
|
||||
cmd.Notify.Type = "telegram"
|
||||
cmd.Notify.Telegram.API = "http://127.0.0.1:12340/"
|
||||
cmd.Notify.Telegram.Token = "blah"
|
||||
cmd.Notify.Type = []string{"email"}
|
||||
cmd.Notify.Email.Host = "127.0.0.1"
|
||||
cmd.Notify.Email.Port = 25
|
||||
cmd.Notify.Email.TLS = false
|
||||
cmd.Notify.Email.From = "from@example.org"
|
||||
cmd.Notify.Email.Username = "test_user"
|
||||
cmd.Notify.Email.Password = "test_password"
|
||||
cmd.Notify.Email.TimeOut = time.Second
|
||||
cmd.Notify.Email.VerificationSubject = "test verification email subject"
|
||||
cmd.UpdateLimit = 10
|
||||
cmd = fn(cmd)
|
||||
|
||||
|
||||
+168
-120
@@ -6,6 +6,7 @@ import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/quotedprintable"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"text/template"
|
||||
@@ -13,18 +14,18 @@ import (
|
||||
|
||||
log "github.com/go-pkgz/lgr"
|
||||
"github.com/go-pkgz/repeater"
|
||||
"github.com/hashicorp/go-multierror"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// EmailParams contain settings for email notifications
|
||||
type EmailParams struct {
|
||||
From string // From email field
|
||||
MsgTemplate string // request message template
|
||||
VerificationSubject string // verification message subject
|
||||
VerificationTemplate string // verification message template
|
||||
BufferSize int // email send buffer size
|
||||
FlushDuration time.Duration // maximum time after which email will me sent, 30s by default
|
||||
From string // from email address
|
||||
MsgTemplate string // request message template
|
||||
VerificationSubject string // verification message subject
|
||||
VerificationTemplate string // verification message template
|
||||
UnsubscribeURL string // full unsubscribe handler URL
|
||||
|
||||
TokenGenFn func(userID, email, site string) (string, error) // Unsubscribe token generation function
|
||||
}
|
||||
|
||||
// SmtpParams contain settings for smtp server connection
|
||||
@@ -73,49 +74,71 @@ type emailMessage struct {
|
||||
|
||||
// msgTmplData store data for message from request template execution
|
||||
type msgTmplData struct {
|
||||
From string
|
||||
To string
|
||||
Orig string
|
||||
Link string
|
||||
PostTitle string
|
||||
CommentUser string
|
||||
ParentUser string
|
||||
CommentText string
|
||||
CommentLink string
|
||||
PostTitle string
|
||||
Email string
|
||||
UnsubscribeLink string
|
||||
}
|
||||
|
||||
// verifyTmplData store data for verification message template execution
|
||||
type verifyTmplData struct {
|
||||
User string
|
||||
Email string
|
||||
Token string
|
||||
Email string
|
||||
Site string
|
||||
}
|
||||
|
||||
const (
|
||||
defaultVerificationSubject = "Email verification"
|
||||
defaultEmailTimeout = 10 * time.Second
|
||||
defaultFlushDuration = time.Second * 30
|
||||
defaultEmailTemplate = `{{.From}}{{if .To}} → {{.To}}{{end}}
|
||||
|
||||
{{.Orig}}
|
||||
|
||||
↦ <a href="{{.Link}}">{{if .PostTitle}}{{.PostTitle}}{{else}}original comment{{end}}</a>
|
||||
defaultEmailTemplate = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<style>img {max-width: 100%; max-height: 300px;} a {color: #4fbbd6;}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div style="font-family: Arial, sans-serif; font-size: 18px;">
|
||||
<h1 style="text-align: center; position: relative; color: #4fbbd6; margin-top: 0.2em;">Remark42</h1>
|
||||
<div style="background-color: #eee; width: 90%; max-width: 800px; margin: 0 auto; border-radius: 0.4em; padding: 0.5em;">
|
||||
<p style="margin: 0 0 0.5em 0; color: #444444;"><b><a href="{{.CommentLink}}" style="color: #4fbbd6 !important;">New reply</a> from {{.CommentUser}} on your comment{{if .PostTitle}} to "{{.PostTitle}}"{{end}}</b></p>
|
||||
<div style="background-color: #fff; margin: 0; padding: 0.5em; word-break: break-all; border-radius: 0.2em;">{{.CommentText}}</div>
|
||||
<p style="text-align: center; position: relative; margin: 0.5em 0 0 0; font-size: 0.8em; opacity: 0.8;"><i>Sent to <a style="color:inherit !important; text-decoration: none !important;" href="mailto:{{.Email}}">{{.Email}}</a> for {{.ParentUser}}</i><br/><br/><a style="color: #4fbbd6 !important;" href="{{.UnsubscribeLink}}">Unsubscribe</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
defaultEmailVerificationTemplate = `Confirmation for {{.User}} {{.Email}}, site {{.Site}}
|
||||
|
||||
Token: {{.Token}}
|
||||
defaultEmailVerificationTemplate = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
</head>
|
||||
<body>
|
||||
<div style="text-align: center; font-family: Arial, sans-serif; font-size: 18px;">
|
||||
<h1 style="position: relative; color: #4fbbd6; margin-top: 0.2em;">Remark42</h1>
|
||||
<p style="position: relative; max-width: 20em; margin: 0 auto 1em auto; line-height: 1.4em;">Confirmation for <b>{{.User}}</b> on site <b>{{.Site}}</b></p>
|
||||
<div style="background-color: #eee; max-width: 20em; margin: 0 auto; border-radius: 0.4em; padding: 0.5em;">
|
||||
<p style="position: relative; margin: 0 0 0.5em 0;">TOKEN</p>
|
||||
<p style="position: relative; font-size: 0.7em; opacity: 0.8;"><i>Copy and paste this text into “token” field on comments page</i></p>
|
||||
<p style="position: relative; font-family: monospace; background-color: #fff; margin: 0; padding: 0.5em; word-break: break-all; text-align: left; border-radius: 0.2em; -webkit-user-select: all; user-select: all;">{{.Token}}</p>
|
||||
</div>
|
||||
<p style="position: relative; margin-top: 2em; font-size: 0.8em; opacity: 0.8;"><i>Sent to {{.Email}}</i></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
)
|
||||
|
||||
// NewEmail makes new Email object, returns it even in case of problems
|
||||
// (e.MsgTemplate parsing error or error while testing smtp connection by credentials provided in emailParams)
|
||||
// NewEmail makes new Email object, returns error in case of e.MsgTemplate or e.VerificationTemplate parsing error
|
||||
func NewEmail(emailParams EmailParams, smtpParams SmtpParams) (*Email, error) {
|
||||
var err error
|
||||
// set up Email emailParams
|
||||
res := Email{EmailParams: emailParams}
|
||||
if res.FlushDuration <= 0 {
|
||||
res.FlushDuration = defaultFlushDuration
|
||||
}
|
||||
if res.BufferSize <= 0 {
|
||||
res.BufferSize = 1
|
||||
}
|
||||
if res.MsgTemplate == "" {
|
||||
res.MsgTemplate = defaultEmailTemplate
|
||||
}
|
||||
@@ -137,25 +160,12 @@ func NewEmail(emailParams EmailParams, smtpParams SmtpParams) (*Email, error) {
|
||||
res.Host, res.Username, res.TimeOut)
|
||||
|
||||
// initialise templates
|
||||
res.msgTmpl, err = template.New("messageFromRequest").Parse(res.MsgTemplate)
|
||||
if err != nil {
|
||||
return &res, errors.Wrapf(err, "can't parse message template")
|
||||
var err error
|
||||
if res.msgTmpl, err = template.New("messageFromRequest").Parse(res.MsgTemplate); err != nil {
|
||||
return nil, errors.Wrapf(err, "can't parse message template")
|
||||
}
|
||||
res.verifyTmpl, err = template.New("messageFromRequest").Parse(res.VerificationTemplate)
|
||||
if err != nil {
|
||||
return &res, errors.Wrapf(err, "can't parse verification template")
|
||||
}
|
||||
|
||||
// establish test connection
|
||||
testSmtpClient, err := res.smtp.Create(res.SmtpParams)
|
||||
if err != nil {
|
||||
return &res, errors.Wrapf(err, "can't establish test connection")
|
||||
}
|
||||
if err = testSmtpClient.Quit(); err != nil {
|
||||
log.Printf("[WARN] failed to send quit command to %s:%d, %v", res.Host, res.Port, err)
|
||||
if err = testSmtpClient.Close(); err != nil {
|
||||
return &res, errors.Wrapf(err, "can't close test smtp connection")
|
||||
}
|
||||
if res.verifyTmpl, err = template.New("messageFromRequest").Parse(res.VerificationTemplate); err != nil {
|
||||
return nil, errors.Wrapf(err, "can't parse verification template")
|
||||
}
|
||||
return &res, err
|
||||
}
|
||||
@@ -186,65 +196,104 @@ func (e *Email) Send(ctx context.Context, req Request) (err error) {
|
||||
|
||||
if req.Comment.ID != "" {
|
||||
if req.parent.User == req.Comment.User {
|
||||
// don't send anything if if user replied to their own Comment
|
||||
// don't send anything if if user replied to their own comment
|
||||
return nil
|
||||
}
|
||||
log.Printf("[DEBUG] send notification via %s, comment id %s", e, req.Comment.ID)
|
||||
msg, err = e.buildMessageFromRequest(req, req.Email)
|
||||
msg, err = e.buildMessageFromRequest(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return e.sendMessage(ctx, emailMessage{from: e.From, to: req.Email, message: msg})
|
||||
return repeater.NewDefault(5, time.Millisecond*250).Do(
|
||||
ctx,
|
||||
func() error {
|
||||
return e.sendMessage(emailMessage{from: e.From, to: req.Email, message: msg})
|
||||
})
|
||||
}
|
||||
|
||||
// buildVerificationMessage generates verification email message based on given input
|
||||
func (e *Email) buildVerificationMessage(user, address, token, site string) (string, error) {
|
||||
func (e *Email) buildVerificationMessage(user, email, token, site string) (string, error) {
|
||||
subject := e.VerificationSubject
|
||||
msg := bytes.Buffer{}
|
||||
err := e.verifyTmpl.Execute(&msg, verifyTmplData{user, address, token, site})
|
||||
err := e.verifyTmpl.Execute(&msg, verifyTmplData{
|
||||
User: user,
|
||||
Token: token,
|
||||
Email: email,
|
||||
Site: site,
|
||||
})
|
||||
if err != nil {
|
||||
return "", errors.Wrapf(err, "error executing template to build verifying message from request")
|
||||
return "", errors.Wrapf(err, "error executing template to build verification message")
|
||||
}
|
||||
return e.buildMessage(subject, msg.String(), address, "text/html"), nil
|
||||
}
|
||||
|
||||
// buildMessage generates email message to send using net/smtp.Data()
|
||||
func (e *Email) buildMessage(subject, body, to, contentType string) (message string) {
|
||||
message += fmt.Sprintf("From: %s\n", e.From)
|
||||
message += fmt.Sprintf("To: %s\n", to)
|
||||
message += fmt.Sprintf("Subject: %s\n", subject)
|
||||
if contentType != "" {
|
||||
message += fmt.Sprintf("MIME-version: 1.0;\nContent-Type: %s; charset=\"UTF-8\";\n", contentType)
|
||||
}
|
||||
message += "\n" + body
|
||||
return message
|
||||
return e.buildMessage(subject, msg.String(), email, "text/html", "")
|
||||
}
|
||||
|
||||
// buildMessageFromRequest generates email message based on Request using e.MsgTemplate
|
||||
func (e *Email) buildMessageFromRequest(req Request, to string) (string, error) {
|
||||
subject := "New comment"
|
||||
func (e *Email) buildMessageFromRequest(req Request) (string, error) {
|
||||
subject := "New reply to your comment"
|
||||
if req.Comment.PostTitle != "" {
|
||||
subject += fmt.Sprintf(" for \"%s\"", req.Comment.PostTitle)
|
||||
}
|
||||
token, err := e.TokenGenFn(req.parent.User.ID, req.Email, req.Comment.Locator.SiteID)
|
||||
unsubscribeLink := e.UnsubscribeURL + "?site=" + req.Comment.Locator.SiteID + "&tkn=" + token
|
||||
if err != nil {
|
||||
return "", errors.Wrapf(err, "error creating token for unsubscribe link")
|
||||
}
|
||||
msg := bytes.Buffer{}
|
||||
err := e.msgTmpl.Execute(&msg, msgTmplData{
|
||||
req.Comment.User.Name,
|
||||
req.parent.User.Name,
|
||||
req.Comment.Orig,
|
||||
req.Comment.Locator.URL + uiNav + req.Comment.ID,
|
||||
req.Comment.PostTitle,
|
||||
err = e.msgTmpl.Execute(&msg, msgTmplData{
|
||||
CommentUser: req.Comment.User.Name,
|
||||
ParentUser: req.parent.User.Name,
|
||||
CommentText: req.Comment.Text,
|
||||
CommentLink: req.Comment.Locator.URL + uiNav + req.Comment.ID,
|
||||
PostTitle: req.Comment.PostTitle,
|
||||
Email: req.Email,
|
||||
UnsubscribeLink: unsubscribeLink,
|
||||
})
|
||||
if err != nil {
|
||||
return "", errors.Wrapf(err, "error executing template to build message from request")
|
||||
return "", errors.Wrapf(err, "error executing template to build comment reply message")
|
||||
}
|
||||
return e.buildMessage(subject, msg.String(), to, "text/html"), nil
|
||||
return e.buildMessage(subject, msg.String(), req.Email, "text/html", unsubscribeLink)
|
||||
}
|
||||
|
||||
// buildMessage generates email message to send using net/smtp.Data()
|
||||
func (e *Email) buildMessage(subject, body, to, contentType, unsubscribeLink string) (message string, err error) {
|
||||
addHeader := func(msg, h, v string) string {
|
||||
msg += fmt.Sprintf("%s: %s\n", h, v)
|
||||
return msg
|
||||
}
|
||||
message = addHeader(message, "From", e.From)
|
||||
message = addHeader(message, "To", to)
|
||||
message = addHeader(message, "Subject", subject)
|
||||
message = addHeader(message, "Content-Transfer-Encoding", "quoted-printable")
|
||||
|
||||
if contentType != "" {
|
||||
message = addHeader(message, "MIME-version", "1.0")
|
||||
message = addHeader(message, "Content-Type", contentType+`; charset="UTF-8"`)
|
||||
}
|
||||
|
||||
if unsubscribeLink != "" {
|
||||
// https://support.google.com/mail/answer/81126 -> "Include option to unsubscribe"
|
||||
message = addHeader(message, "List-Unsubscribe-Post", "List-Unsubscribe=One-Click")
|
||||
message = addHeader(message, "List-Unsubscribe", "<"+unsubscribeLink+">")
|
||||
}
|
||||
|
||||
message = addHeader(message, "Date", time.Now().Format(time.RFC1123Z))
|
||||
|
||||
buff := &bytes.Buffer{}
|
||||
qp := quotedprintable.NewWriter(buff)
|
||||
if _, err := qp.Write([]byte(body)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer qp.Close()
|
||||
m := buff.String()
|
||||
message += "\n" + m
|
||||
return message, nil
|
||||
}
|
||||
|
||||
// sendMessage sends messages to server in a new connection, closing the connection after finishing.
|
||||
// Thread safe.
|
||||
func (e *Email) sendMessage(ctx context.Context, m emailMessage) error {
|
||||
func (e *Email) sendMessage(m emailMessage) error {
|
||||
if e.smtp == nil {
|
||||
return errors.New("sendMessage called without smtpClient set")
|
||||
}
|
||||
@@ -253,54 +302,60 @@ func (e *Email) sendMessage(ctx context.Context, m emailMessage) error {
|
||||
return errors.Wrap(err, "failed to make smtp Create")
|
||||
}
|
||||
|
||||
errs := new(multierror.Error)
|
||||
|
||||
err = repeater.NewDefault(5, time.Millisecond*250).Do(ctx, func() error {
|
||||
if err := smtpClient.Mail(m.from); err != nil {
|
||||
return errors.Wrapf(err, "bad from address %q", m.from)
|
||||
}
|
||||
if err := smtpClient.Rcpt(m.to); err != nil {
|
||||
return errors.Wrapf(err, "bad to address %q", m.to)
|
||||
}
|
||||
|
||||
writer, err := smtpClient.Data()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "can't make email writer")
|
||||
}
|
||||
defer func() {
|
||||
if err = writer.Close(); err != nil {
|
||||
log.Printf("[WARN] can't close smtp body writer, %v", err)
|
||||
defer func() {
|
||||
if err := smtpClient.Quit(); err != nil {
|
||||
log.Printf("[WARN] failed to send quit command to %s:%d, %v", e.Host, e.Port, err)
|
||||
if err := smtpClient.Close(); err != nil {
|
||||
log.Printf("[WARN] can't close smtp connection, %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
buf := bytes.NewBufferString(m.message)
|
||||
if _, err = buf.WriteTo(writer); err != nil {
|
||||
return errors.Wrapf(err, "failed to send email body to %q", m.to)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
|
||||
if err := smtpClient.Mail(m.from); err != nil {
|
||||
return errors.Wrapf(err, "bad from address %q", m.from)
|
||||
}
|
||||
if err := smtpClient.Rcpt(m.to); err != nil {
|
||||
return errors.Wrapf(err, "bad to address %q", m.to)
|
||||
}
|
||||
|
||||
writer, err := smtpClient.Data()
|
||||
if err != nil {
|
||||
errs = multierror.Append(errs, errors.Wrapf(err, "can't send message to %s", m.to))
|
||||
return errors.Wrap(err, "can't make email writer")
|
||||
}
|
||||
|
||||
if err := smtpClient.Quit(); err != nil {
|
||||
log.Printf("[WARN] failed to send quit command to %s:%d, %v", e.Host, e.Port, err)
|
||||
if err := smtpClient.Close(); err != nil {
|
||||
log.Printf("[WARN] can't close smtp connection, %v", err)
|
||||
errs = multierror.Append(errs, err)
|
||||
defer func() {
|
||||
if err = writer.Close(); err != nil {
|
||||
log.Printf("[WARN] can't close smtp body writer, %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
buf := bytes.NewBufferString(m.message)
|
||||
if _, err = buf.WriteTo(writer); err != nil {
|
||||
return errors.Wrapf(err, "failed to send email body to %q", m.to)
|
||||
}
|
||||
return errors.Wrapf(errs.ErrorOrNil(), "problems with sending message")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// String representation of Email object
|
||||
func (e *Email) String() string {
|
||||
return fmt.Sprintf("email: from %q using '%s'@'%s':%d", e.From, e.Username, e.Host, e.Port)
|
||||
return fmt.Sprintf("email: from %q with username '%s' at server %s:%d", e.From, e.Username, e.Host, e.Port)
|
||||
}
|
||||
|
||||
// Create establish SMTP connection with server using credentials in smtpClientWithCreator.SmtpParams
|
||||
// and returns pointer to it. Thread safe.
|
||||
func (s *emailClient) Create(params SmtpParams) (smtpClient, error) {
|
||||
authenticate := func(c *smtp.Client) error {
|
||||
if params.Username == "" || params.Password == "" {
|
||||
return nil
|
||||
}
|
||||
auth := smtp.PlainAuth("", params.Username, params.Password, params.Host)
|
||||
if err := c.Auth(auth); err != nil {
|
||||
return errors.Wrapf(err, "failed to auth to smtp %s:%d", params.Host, params.Port)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var c *smtp.Client
|
||||
srvAddress := fmt.Sprintf("%s:%d", params.Host, params.Port)
|
||||
if params.TLS {
|
||||
@@ -315,7 +370,7 @@ func (s *emailClient) Create(params SmtpParams) (smtpClient, error) {
|
||||
if c, err = smtp.NewClient(conn, params.Host); err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to make smtp client for %s", srvAddress)
|
||||
}
|
||||
return c, nil
|
||||
return c, authenticate(c)
|
||||
}
|
||||
|
||||
conn, err := net.DialTimeout("tcp", srvAddress, params.TimeOut)
|
||||
@@ -328,12 +383,5 @@ func (s *emailClient) Create(params SmtpParams) (smtpClient, error) {
|
||||
return nil, errors.Wrap(err, "failed to dial")
|
||||
}
|
||||
|
||||
if params.Username != "" && params.Password != "" {
|
||||
auth := smtp.PlainAuth("", params.Username, params.Password, params.Host)
|
||||
if err := c.Auth(auth); err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to auth to smtp %s:%d", params.Host, params.Port)
|
||||
}
|
||||
}
|
||||
|
||||
return c, nil
|
||||
return c, authenticate(c)
|
||||
}
|
||||
|
||||
@@ -19,25 +19,21 @@ import (
|
||||
func TestEmailNew(t *testing.T) {
|
||||
var testSet = []struct {
|
||||
name string
|
||||
template bool
|
||||
err bool
|
||||
errText string
|
||||
emailParams EmailParams
|
||||
smtpParams SmtpParams
|
||||
}{
|
||||
{name: "with connection error", template: true, err: true},
|
||||
{name: "empty"},
|
||||
{name: "with template parse error",
|
||||
err: true, errText: "can't parse message template: template: messageFromRequest:1: unexpected unclosed action in command",
|
||||
emailParams: EmailParams{
|
||||
From: "test@from",
|
||||
MsgTemplate: "{{",
|
||||
BufferSize: 10,
|
||||
FlushDuration: time.Second,
|
||||
MsgTemplate: "{{",
|
||||
}},
|
||||
{name: "with verification template parse error",
|
||||
err: true, errText: "can't parse verification template: template: messageFromRequest:1: unexpected unclosed action in command",
|
||||
template: true,
|
||||
emailParams: EmailParams{
|
||||
From: "test@from",
|
||||
VerificationTemplate: "{{",
|
||||
},
|
||||
smtpParams: SmtpParams{
|
||||
@@ -49,71 +45,70 @@ func TestEmailNew(t *testing.T) {
|
||||
TimeOut: time.Second,
|
||||
},
|
||||
},
|
||||
{name: "normal creation",
|
||||
err: false, errText: "can't parse verification template: template: messageFromRequest:1: unexpected unclosed action in command",
|
||||
emailParams: EmailParams{
|
||||
From: "test@from",
|
||||
},
|
||||
smtpParams: SmtpParams{
|
||||
Host: "test@host",
|
||||
Port: 1000,
|
||||
TLS: true,
|
||||
Username: "test@username",
|
||||
Password: "test@password",
|
||||
TimeOut: time.Second,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, d := range testSet {
|
||||
d := d // capture range variable
|
||||
t.Run(d.name, func(t *testing.T) {
|
||||
email, err := NewEmail(d.emailParams, d.smtpParams)
|
||||
|
||||
if d.err && d.errText == "" {
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, email)
|
||||
} else if d.err && d.errText != "" {
|
||||
assert.EqualError(t, err, d.errText)
|
||||
assert.Nil(t, email)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
assert.NotNil(t, email, "email returned")
|
||||
|
||||
assert.NotNil(t, email, "email returned")
|
||||
if d.template {
|
||||
assert.NotNil(t, email.msgTmpl, "e.template is set")
|
||||
} else {
|
||||
assert.Nil(t, email.msgTmpl, "e.template is not set")
|
||||
}
|
||||
if d.emailParams.MsgTemplate == "" {
|
||||
assert.Equal(t, defaultEmailTemplate, email.EmailParams.MsgTemplate, "empty emailParams.MsgTemplate changed to default")
|
||||
} else {
|
||||
assert.Equal(t, d.emailParams.MsgTemplate, email.EmailParams.MsgTemplate, "emailParams.MsgTemplate unchanged after creation")
|
||||
assert.Equal(t, defaultEmailVerificationTemplate, email.EmailParams.VerificationTemplate, "empty emailParams.VerificationTemplate changed to default")
|
||||
assert.Equal(t, d.emailParams.From, email.EmailParams.From, "emailParams.From unchanged after creation")
|
||||
if d.smtpParams.TimeOut == 0 {
|
||||
assert.Equal(t, defaultEmailTimeout, email.TimeOut, "empty emailParams.TimeOut changed to default")
|
||||
} else {
|
||||
assert.Equal(t, d.smtpParams.TimeOut, email.TimeOut, "emailParams.TimOut unchanged after creation")
|
||||
}
|
||||
assert.Equal(t, d.smtpParams.Host, email.Host, "emailParams.Host unchanged after creation")
|
||||
assert.Equal(t, d.smtpParams.Username, email.Username, "emailParams.Username unchanged after creation")
|
||||
assert.Equal(t, d.smtpParams.Password, email.Password, "emailParams.Password unchanged after creation")
|
||||
assert.Equal(t, d.smtpParams.Port, email.Port, "emailParams.Port unchanged after creation")
|
||||
assert.Equal(t, d.smtpParams.TLS, email.TLS, "emailParams.TLS unchanged after creation")
|
||||
}
|
||||
if d.emailParams.FlushDuration == 0 {
|
||||
assert.Equal(t, defaultFlushDuration, email.EmailParams.FlushDuration, "empty emailParams.FlushDuration changed to default")
|
||||
} else {
|
||||
assert.Equal(t, d.emailParams.FlushDuration, email.EmailParams.FlushDuration, "emailParams.FlushDuration unchanged after creation")
|
||||
}
|
||||
if d.emailParams.BufferSize == 0 {
|
||||
assert.Equal(t, 1, email.EmailParams.BufferSize, "empty emailParams.BufferSize changed to default")
|
||||
} else {
|
||||
assert.Equal(t, d.emailParams.BufferSize, email.EmailParams.BufferSize, "emailParams.BufferSize unchanged after creation")
|
||||
}
|
||||
assert.Equal(t, d.emailParams.From, email.EmailParams.From, "emailParams.From unchanged after creation")
|
||||
if d.smtpParams.TimeOut == 0 {
|
||||
assert.Equal(t, defaultEmailTimeout, email.TimeOut, "empty emailParams.TimeOut changed to default")
|
||||
} else {
|
||||
assert.Equal(t, d.smtpParams.TimeOut, email.TimeOut, "emailParams.TimOut unchanged after creation")
|
||||
}
|
||||
assert.Equal(t, d.smtpParams.Host, email.Host, "emailParams.Host unchanged after creation")
|
||||
assert.Equal(t, d.smtpParams.Username, email.Username, "emailParams.Username unchanged after creation")
|
||||
assert.Equal(t, d.smtpParams.Password, email.Password, "emailParams.Password unchanged after creation")
|
||||
assert.Equal(t, d.smtpParams.Port, email.Port, "emailParams.Port unchanged after creation")
|
||||
assert.Equal(t, d.smtpParams.TLS, email.TLS, "emailParams.TLS unchanged after creation")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailSendErrors(t *testing.T) {
|
||||
var err error
|
||||
e := Email{EmailParams: EmailParams{FlushDuration: time.Second}}
|
||||
e := Email{}
|
||||
e.TokenGenFn = TokenGenFn
|
||||
|
||||
e.verifyTmpl, err = template.New("test").Parse("{{.Test}}")
|
||||
assert.NoError(t, err)
|
||||
assert.EqualError(t, e.Send(context.Background(), Request{Email: "bad@example.org", Verification: VerificationMetadata{Token: "some"}}),
|
||||
"error executing template to build verifying message from request: template: test:1:2: executing \"test\" at <.Test>: can't evaluate field Test in type notify.verifyTmplData")
|
||||
"error executing template to build verification message: template: test:1:2: executing \"test\" at <.Test>: can't evaluate field Test in type notify.verifyTmplData")
|
||||
e.verifyTmpl, err = template.New("test").Parse(defaultEmailVerificationTemplate)
|
||||
assert.NoError(t, err)
|
||||
|
||||
e.msgTmpl, err = template.New("test").Parse("{{.Test}}")
|
||||
assert.NoError(t, err)
|
||||
assert.EqualError(t, e.Send(context.Background(), Request{Comment: store.Comment{ID: "999"}, parent: store.Comment{User: store.User{ID: "test"}}, Email: "bad@example.org"}),
|
||||
"error executing template to build message from request: template: test:1:2: executing \"test\" at <.Test>: can't evaluate field Test in type notify.msgTmplData")
|
||||
"error executing template to build comment reply message: template: test:1:2: executing \"test\" at <.Test>: can't evaluate field Test in type notify.msgTmplData")
|
||||
e.msgTmpl, err = template.New("test").Parse(defaultEmailTemplate)
|
||||
assert.NoError(t, err)
|
||||
|
||||
@@ -121,11 +116,17 @@ func TestEmailSendErrors(t *testing.T) {
|
||||
cancel()
|
||||
assert.EqualError(t, e.Send(ctx, Request{Comment: store.Comment{ID: "999"}, parent: store.Comment{User: store.User{ID: "test"}}, Email: "bad@example.org"}),
|
||||
"sending message to \"bad@example.org\" aborted due to canceled context")
|
||||
|
||||
e.smtp = &fakeTestSMTP{}
|
||||
assert.EqualError(t, e.Send(context.Background(), Request{Comment: store.Comment{ID: "999"}, parent: store.Comment{User: store.User{ID: "error"}}, Email: "bad@example.org"}),
|
||||
"error creating token for unsubscribe link: token generation error")
|
||||
e.msgTmpl, err = template.New("test").Parse(defaultEmailTemplate)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestEmailSend_ExitConditions(t *testing.T) {
|
||||
email, err := NewEmail(EmailParams{}, SmtpParams{})
|
||||
assert.Error(t, err, "error match expected")
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, email, "expecting email returned")
|
||||
// prevent triggering e.autoFlush creation
|
||||
emptyRequest := Request{Comment: store.Comment{ID: "999"}}
|
||||
@@ -143,64 +144,68 @@ func TestEmailSendClientError(t *testing.T) {
|
||||
err string
|
||||
}{
|
||||
{name: "failed to verify receiver", smtp: &fakeTestSMTP{fail: map[string]bool{"mail": true}},
|
||||
err: "problems with sending message: 1 error occurred:\n\t* can't send message to : bad from address \"\": failed to verify sender\n\n"},
|
||||
err: "bad from address \"\": failed to verify sender"},
|
||||
{name: "failed to verify sender", smtp: &fakeTestSMTP{fail: map[string]bool{"rcpt": true}},
|
||||
err: "problems with sending message: 1 error occurred:\n\t* can't send message to : bad to address \"\": failed to verify receiver\n\n"},
|
||||
{name: "failed to close connection", smtp: &fakeTestSMTP{fail: map[string]bool{"quit": true, "close": true}},
|
||||
err: "problems with sending message: 1 error occurred:\n\t* failed to close\n\n"},
|
||||
err: "bad to address \"\": failed to verify receiver"},
|
||||
{name: "failed to close connection", smtp: &fakeTestSMTP{fail: map[string]bool{"quit": true, "close": true}}},
|
||||
{name: "failed to make email writer", smtp: &fakeTestSMTP{fail: map[string]bool{"data": true}},
|
||||
err: "problems with sending message: 1 error occurred:\n\t* can't send message to : can't make email writer: failed to send\n\n"},
|
||||
err: "can't make email writer: failed to send"},
|
||||
}
|
||||
for _, d := range testSet {
|
||||
d := d // capture range variable
|
||||
t.Run(d.name, func(t *testing.T) {
|
||||
e := Email{smtp: d.smtp}
|
||||
assert.EqualError(t, e.sendMessage(context.Background(), emailMessage{}), d.err,
|
||||
"expected error for e.sendMessage")
|
||||
if d.err != "" {
|
||||
assert.EqualError(t, e.sendMessage(emailMessage{}), d.err,
|
||||
"expected error for e.sendMessage")
|
||||
} else {
|
||||
assert.NoError(t, e.sendMessage(emailMessage{}),
|
||||
"expected no error for e.sendMessage")
|
||||
}
|
||||
})
|
||||
}
|
||||
e := Email{}
|
||||
e.smtp = nil
|
||||
assert.Error(t, e.sendMessage(context.Background(), emailMessage{}),
|
||||
assert.Error(t, e.sendMessage(emailMessage{}),
|
||||
"nil e.smtp should return error")
|
||||
e.smtp = &fakeTestSMTP{}
|
||||
assert.NoError(t, e.sendMessage(context.Background(), emailMessage{}), "",
|
||||
assert.NoError(t, e.sendMessage(emailMessage{}), "",
|
||||
"no error expected for e.sendMessage in normal flow")
|
||||
e.smtp = &fakeTestSMTP{fail: map[string]bool{"quit": true}}
|
||||
assert.NoError(t, e.sendMessage(context.Background(), emailMessage{}), "",
|
||||
assert.NoError(t, e.sendMessage(emailMessage{}), "",
|
||||
"no error expected for e.sendMessage with failed smtpClient.Quit but successful smtpClient.Close")
|
||||
e.smtp = &fakeTestSMTP{fail: map[string]bool{"create": true}}
|
||||
assert.EqualError(t, e.sendMessage(context.Background(), emailMessage{}), "failed to make smtp Create: failed to create client",
|
||||
assert.EqualError(t, e.sendMessage(emailMessage{}), "failed to make smtp Create: failed to create client",
|
||||
"e.send called without smtpClient set returns error")
|
||||
}
|
||||
|
||||
func TestEmail_Send(t *testing.T) {
|
||||
const expectedAnswer = `From: from@example.org
|
||||
To: test@example.org
|
||||
Subject: New comment for "test_title"
|
||||
MIME-version: 1.0;
|
||||
Content-Type: text/html; charset="UTF-8";
|
||||
|
||||
test_user
|
||||
|
||||
|
||||
|
||||
↦ <a href="#remark42__comment-999">test_title</a>
|
||||
`
|
||||
req := Request{Comment: store.Comment{ID: "999", User: store.User{Name: "test_user"}, PostTitle: "test_title"}, Email: "test@example.org"}
|
||||
e, err := NewEmail(EmailParams{From: "from@example.org"}, SmtpParams{})
|
||||
assert.Error(t, err, "connection error expected")
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, e)
|
||||
fakeSmtp := fakeTestSMTP{}
|
||||
e.smtp = &fakeSmtp
|
||||
e.TokenGenFn = TokenGenFn
|
||||
e.UnsubscribeURL = "https://remark42.com/api/v1/email/unsubscribe"
|
||||
req := Request{
|
||||
Comment: store.Comment{ID: "999", User: store.User{Name: "test_user"}, PostTitle: "test_title"},
|
||||
parent: store.Comment{ID: "1", User: store.User{Name: "parent_user"}},
|
||||
Email: "test@example.org"}
|
||||
assert.NoError(t, e.Send(context.TODO(), req))
|
||||
assert.Equal(t, "from@example.org", fakeSmtp.readMail())
|
||||
assert.Equal(t, 1, fakeSmtp.readQuitCount())
|
||||
assert.Equal(t, "test@example.org", fakeSmtp.readRcpt())
|
||||
// test buildMessageFromRequest separately for message text
|
||||
res, err := e.buildMessageFromRequest(req, "test@example.org")
|
||||
res, err := e.buildMessageFromRequest(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expectedAnswer, res)
|
||||
assert.Contains(t, res, `From: from@example.org
|
||||
To: test@example.org
|
||||
Subject: New reply to your comment for "test_title"
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
MIME-version: 1.0
|
||||
Content-Type: text/html; charset="UTF-8"
|
||||
List-Unsubscribe-Post: List-Unsubscribe=One-Click
|
||||
List-Unsubscribe: <https://remark42.com/api/v1/email/unsubscribe?site=&tkn=token>
|
||||
Date: `)
|
||||
}
|
||||
|
||||
type fakeTestSMTP struct {
|
||||
@@ -286,6 +291,13 @@ func (f *fakeTestSMTP) readQuitCount() int {
|
||||
return f.quitCount
|
||||
}
|
||||
|
||||
func TokenGenFn(user, _, _ string) (string, error) {
|
||||
if user == "error" {
|
||||
return "", errors.New("token generation error")
|
||||
}
|
||||
return "token", nil
|
||||
}
|
||||
|
||||
type nopCloser struct {
|
||||
io.Writer
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ type Destination interface {
|
||||
// Store defines the minimal interface accessing stored comments used by notifier
|
||||
type Store interface {
|
||||
Get(locator store.Locator, id string, user store.User) (store.Comment, error)
|
||||
GetUserEmail(locator store.Locator, userID string) (string, error)
|
||||
}
|
||||
|
||||
// Request notification either about comment or about particular user verification
|
||||
@@ -81,6 +82,10 @@ func (s *Service) Submit(req Request) {
|
||||
if s.dataService != nil && req.Comment.ParentID != "" {
|
||||
if p, err := s.dataService.Get(req.Comment.Locator, req.Comment.ParentID, store.User{}); err == nil {
|
||||
req.parent = p
|
||||
req.Email, err = s.dataService.GetUserEmail(req.Comment.Locator, p.User.ID)
|
||||
if err != nil {
|
||||
log.Printf("[WARN] can't read email for %s, %v", p.User.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
select {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/go-pkgz/lgr"
|
||||
)
|
||||
|
||||
type MockDest struct {
|
||||
data []Request
|
||||
id int
|
||||
closed bool
|
||||
lock sync.Mutex
|
||||
}
|
||||
|
||||
func (m *MockDest) Send(ctx context.Context, r Request) error {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
select {
|
||||
case <-time.After(10 * time.Millisecond):
|
||||
m.data = append(m.data, r)
|
||||
log.Printf("sent %s -> %d", r.Comment.ID, m.id)
|
||||
case <-ctx.Done():
|
||||
log.Printf("ctx closed %d", m.id)
|
||||
m.closed = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockDest) Get() []Request {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
res := make([]Request, len(m.data))
|
||||
copy(res, m.data)
|
||||
return res
|
||||
}
|
||||
func (m *MockDest) String() string { return fmt.Sprintf("mock id=%d, closed=%v", m.id, m.closed) }
|
||||
@@ -1,16 +1,13 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
log "github.com/go-pkgz/lgr"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/umputun/remark/backend/app/store"
|
||||
@@ -27,7 +24,7 @@ func TestService_NoDestinations(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestService_WithDestinations(t *testing.T) {
|
||||
d1, d2 := &mockDest{id: 1}, &mockDest{id: 2}
|
||||
d1, d2 := &MockDest{id: 1}, &MockDest{id: 2}
|
||||
s := NewService(nil, 1, d1, d2)
|
||||
assert.NotNil(t, s)
|
||||
|
||||
@@ -39,53 +36,53 @@ func TestService_WithDestinations(t *testing.T) {
|
||||
time.Sleep(time.Millisecond * 110)
|
||||
s.Close()
|
||||
|
||||
assert.Equal(t, 3, len(d1.get()), "got all comments to d1")
|
||||
assert.Equal(t, 3, len(d2.get()), "got all comments to d2")
|
||||
assert.Equal(t, 3, len(d1.Get()), "got all comments to d1")
|
||||
assert.Equal(t, 3, len(d2.Get()), "got all comments to d2")
|
||||
|
||||
assert.Equal(t, "100", d1.get()[0].Comment.ID)
|
||||
assert.Equal(t, "101", d1.get()[1].Comment.ID)
|
||||
assert.Equal(t, "102", d1.get()[2].Comment.ID)
|
||||
assert.Equal(t, "100", d1.Get()[0].Comment.ID)
|
||||
assert.Equal(t, "101", d1.Get()[1].Comment.ID)
|
||||
assert.Equal(t, "102", d1.Get()[2].Comment.ID)
|
||||
}
|
||||
|
||||
func TestService_WithDrops(t *testing.T) {
|
||||
d1, d2 := &mockDest{id: 1}, &mockDest{id: 2}
|
||||
d1, d2 := &MockDest{id: 1}, &MockDest{id: 2}
|
||||
s := NewService(nil, 1, d1, d2)
|
||||
assert.NotNil(t, s)
|
||||
|
||||
s.Submit(Request{Comment: store.Comment{ID: "100"}})
|
||||
s.Submit(Request{Comment: store.Comment{ID: "101"}})
|
||||
time.Sleep(time.Millisecond * 110)
|
||||
time.Sleep(time.Millisecond * 11)
|
||||
s.Submit(Request{Comment: store.Comment{ID: "102"}})
|
||||
time.Sleep(time.Millisecond * 110)
|
||||
time.Sleep(time.Millisecond * 11)
|
||||
s.Close()
|
||||
|
||||
s.Submit(Request{Comment: store.Comment{ID: "111"}}) // safe to send after close
|
||||
|
||||
assert.Equal(t, 2, len(d1.get()), "one comment dropped from d1")
|
||||
assert.Equal(t, 2, len(d2.get()), "one comment dropped from d2")
|
||||
assert.Equal(t, 2, len(d1.Get()), "one comment dropped from d1")
|
||||
assert.Equal(t, 2, len(d2.Get()), "one comment dropped from d2")
|
||||
}
|
||||
|
||||
func TestService_Many(t *testing.T) {
|
||||
d1, d2 := &mockDest{id: 1}, &mockDest{id: 2}
|
||||
d1, d2 := &MockDest{id: 1}, &MockDest{id: 2}
|
||||
s := NewService(nil, 5, d1, d2)
|
||||
assert.NotNil(t, s)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
s.Submit(Request{Comment: store.Comment{ID: fmt.Sprintf("%d", 100+i)}})
|
||||
time.Sleep(time.Millisecond * time.Duration(rand.Int31n(200)))
|
||||
time.Sleep(time.Millisecond * time.Duration(rand.Int31n(20)))
|
||||
}
|
||||
s.Close()
|
||||
time.Sleep(time.Millisecond * 10)
|
||||
|
||||
assert.NotEqual(t, 10, len(d1.get()), "some comments dropped from d1")
|
||||
assert.NotEqual(t, 10, len(d2.get()), "some comments dropped from d2")
|
||||
assert.NotEqual(t, 10, len(d1.Get()), "some comments dropped from d1")
|
||||
assert.NotEqual(t, 10, len(d2.Get()), "some comments dropped from d2")
|
||||
|
||||
assert.True(t, d1.closed)
|
||||
assert.True(t, d2.closed)
|
||||
}
|
||||
|
||||
func TestService_WithParent(t *testing.T) {
|
||||
dest := &mockDest{id: 1}
|
||||
dest := &MockDest{id: 1}
|
||||
dataStore := &mockStore{data: map[string]store.Comment{}}
|
||||
|
||||
dataStore.data["p1"] = store.Comment{ID: "p1"}
|
||||
@@ -100,7 +97,7 @@ func TestService_WithParent(t *testing.T) {
|
||||
time.Sleep(time.Millisecond * 110)
|
||||
s.Close()
|
||||
|
||||
destRes := dest.get()
|
||||
destRes := dest.Get()
|
||||
assert.Equal(t, 2, len(destRes), "two comment notified")
|
||||
assert.Equal(t, "p1", destRes[0].Comment.ParentID)
|
||||
assert.Equal(t, "p1", destRes[0].parent.ID)
|
||||
@@ -115,42 +112,16 @@ func TestService_Nop(t *testing.T) {
|
||||
assert.Equal(t, uint32(1), atomic.LoadUint32(&s.closed))
|
||||
}
|
||||
|
||||
type mockDest struct {
|
||||
data []Request
|
||||
id int
|
||||
closed bool
|
||||
lock sync.Mutex
|
||||
}
|
||||
|
||||
func (m *mockDest) Send(ctx context.Context, r Request) error {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
select {
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
m.data = append(m.data, r)
|
||||
log.Printf("sent %s -> %d", r.Comment.ID, m.id)
|
||||
case <-ctx.Done():
|
||||
log.Printf("ctx closed %d", m.id)
|
||||
m.closed = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockDest) get() []Request {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
res := make([]Request, len(m.data))
|
||||
copy(res, m.data)
|
||||
return res
|
||||
}
|
||||
func (m *mockDest) String() string { return fmt.Sprintf("mock id=%d, closed=%v", m.id, m.closed) }
|
||||
|
||||
type mockStore struct{ data map[string]store.Comment }
|
||||
|
||||
func (m *mockStore) Get(_ store.Locator, id string, user store.User) (store.Comment, error) {
|
||||
func (m mockStore) Get(_ store.Locator, id string, _ store.User) (store.Comment, error) {
|
||||
res, ok := m.data[id]
|
||||
if !ok {
|
||||
return store.Comment{}, errors.New("no such id")
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (m mockStore) GetUserEmail(_ store.Locator, _ string) (string, error) {
|
||||
return "", errors.New("no such user")
|
||||
}
|
||||
|
||||
@@ -235,6 +235,8 @@ func (s *Rest) routes() chi.Router {
|
||||
ropen.Get("/list", s.pubRest.listCtrl)
|
||||
ropen.Post("/preview", s.pubRest.previewCommentCtrl)
|
||||
ropen.Get("/info", s.pubRest.infoCtrl)
|
||||
ropen.Get("/email/unsubscribe", s.privRest.emailUnsubscribeCtrl)
|
||||
ropen.Post("/email/unsubscribe", s.privRest.emailUnsubscribeCtrl)
|
||||
ropen.Get("/img", s.ImageProxy.Handler)
|
||||
|
||||
ropen.Route("/rss", func(rrss chi.Router) {
|
||||
@@ -308,6 +310,10 @@ func (s *Rest) routes() chi.Router {
|
||||
rauth.Post("/comment", s.privRest.createCommentCtrl)
|
||||
rauth.With(rejectAnonUser).Put("/vote/{id}", s.privRest.voteCtrl)
|
||||
rauth.With(rejectAnonUser).Post("/deleteme", s.privRest.deleteMeCtrl)
|
||||
rauth.With(rejectAnonUser).Get("/email", s.privRest.getEmailCtrl)
|
||||
rauth.With(rejectAnonUser).Post("/email/subscribe", s.privRest.sendEmailConfirmationCtrl)
|
||||
rauth.With(rejectAnonUser).Post("/email/confirm", s.privRest.setConfirmedEmailCtrl)
|
||||
rauth.With(rejectAnonUser).Delete("/email", s.privRest.deleteEmailCtrl)
|
||||
})
|
||||
|
||||
// protected routes, anonymous rejected
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/umputun/remark/backend/app/notify"
|
||||
"github.com/umputun/remark/backend/app/rest"
|
||||
"github.com/umputun/remark/backend/app/store"
|
||||
"github.com/umputun/remark/backend/app/store/engine"
|
||||
"github.com/umputun/remark/backend/app/store/image"
|
||||
"github.com/umputun/remark/backend/app/store/service"
|
||||
)
|
||||
@@ -43,6 +44,9 @@ type privStore interface {
|
||||
Vote(req service.VoteReq) (comment store.Comment, err error)
|
||||
Get(locator store.Locator, commentID string, user store.User) (store.Comment, error)
|
||||
User(siteID, userID string, limit, skip int, user store.User) ([]store.Comment, error)
|
||||
GetUserEmail(locator store.Locator, userID string) (string, error)
|
||||
SetUserEmail(locator store.Locator, userID string, value string) (string, error)
|
||||
DeleteUserDetail(locator store.Locator, userID string, detail engine.UserDetail) error
|
||||
ValidateComment(c *store.Comment) error
|
||||
IsVerified(siteID string, userID string) bool
|
||||
IsReadOnly(locator store.Locator) bool
|
||||
@@ -221,6 +225,209 @@ func (s *private) voteCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
render.JSON(w, r, R.JSON{"id": comment.ID, "score": comment.Score})
|
||||
}
|
||||
|
||||
// getEmailCtrl gets email address for authenticated user.
|
||||
// GET /email?site=siteID
|
||||
func (s *private) getEmailCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
user := rest.MustGetUserInfo(r)
|
||||
siteID := r.URL.Query().Get("site")
|
||||
address, err := s.dataService.GetUserEmail(store.Locator{SiteID: siteID}, user.ID)
|
||||
if err != nil {
|
||||
log.Printf("[WARN] can't read email for %s, %v", user.ID, err)
|
||||
}
|
||||
|
||||
render.JSON(w, r, R.JSON{"user": user, "address": address})
|
||||
}
|
||||
|
||||
// sendEmailConfirmationCtrl gets address and siteID from query, makes confirmation token and sends it to user.
|
||||
// GET /email/subscribe?site=siteID&address=someone@example.com
|
||||
func (s *private) sendEmailConfirmationCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
user := rest.MustGetUserInfo(r)
|
||||
address := r.URL.Query().Get("address")
|
||||
siteID := r.URL.Query().Get("site")
|
||||
if address == "" {
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, errors.New("missing parameter"), "address parameter is required", rest.ErrInternal)
|
||||
return
|
||||
}
|
||||
existingAddress, err := s.dataService.GetUserEmail(store.Locator{SiteID: siteID}, user.ID)
|
||||
if err != nil {
|
||||
log.Printf("[WARN] can't read email for %s, %v", user.ID, err)
|
||||
}
|
||||
if address == existingAddress {
|
||||
rest.SendErrorJSON(w, r, http.StatusConflict, errors.New("already verified"), "email address is already verified for this user", rest.ErrInternal)
|
||||
return
|
||||
}
|
||||
claims := token.Claims{
|
||||
Handshake: &token.Handshake{ID: user.ID + "::" + address},
|
||||
StandardClaims: jwt.StandardClaims{
|
||||
Audience: r.URL.Query().Get("site"),
|
||||
ExpiresAt: time.Now().Add(30 * time.Minute).Unix(),
|
||||
NotBefore: time.Now().Add(-1 * time.Minute).Unix(),
|
||||
Issuer: "remark42",
|
||||
},
|
||||
}
|
||||
|
||||
tkn, err := s.authenticator.TokenService().Token(claims)
|
||||
if err != nil {
|
||||
rest.SendErrorJSON(w, r, http.StatusForbidden, err, "failed to make verification token", rest.ErrInternal)
|
||||
return
|
||||
}
|
||||
|
||||
s.notifyService.Submit(
|
||||
notify.Request{
|
||||
Email: address,
|
||||
Verification: notify.VerificationMetadata{
|
||||
Locator: store.Locator{SiteID: siteID},
|
||||
User: user.Name,
|
||||
Token: tkn,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
render.JSON(w, r, R.JSON{"user": user, "address": address})
|
||||
}
|
||||
|
||||
// setConfirmedEmailCtrl uses provided token parameter (generated by sendEmailConfirmationCtrl) to set email and add it to user token
|
||||
// PUT /email/confirm?site=siteID&tkn=jwt
|
||||
func (s *private) setConfirmedEmailCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
tkn := r.URL.Query().Get("tkn")
|
||||
if tkn == "" {
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, errors.New("missing parameter"), "token parameter is required", rest.ErrInternal)
|
||||
return
|
||||
}
|
||||
user := rest.MustGetUserInfo(r)
|
||||
locator := store.Locator{SiteID: r.URL.Query().Get("site")}
|
||||
|
||||
confClaims, err := s.authenticator.TokenService().Parse(tkn)
|
||||
if err != nil {
|
||||
rest.SendErrorJSON(w, r, http.StatusForbidden, err, "failed to verify confirmation token", rest.ErrInternal)
|
||||
return
|
||||
}
|
||||
|
||||
if s.authenticator.TokenService().IsExpired(confClaims) {
|
||||
rest.SendErrorJSON(w, r, http.StatusForbidden, errors.New("expired"), "failed to verify confirmation token", rest.ErrInternal)
|
||||
return
|
||||
}
|
||||
|
||||
elems := strings.Split(confClaims.Handshake.ID, "::")
|
||||
if len(elems) != 2 || elems[0] != user.ID {
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, errors.New(confClaims.Handshake.ID), "invalid handshake token", rest.ErrInternal)
|
||||
return
|
||||
}
|
||||
address := elems[1]
|
||||
|
||||
log.Printf("[DEBUG] set email for user %s", user.ID)
|
||||
|
||||
val, err := s.dataService.SetUserEmail(locator, user.ID, address)
|
||||
if err != nil {
|
||||
code := parseError(err, rest.ErrInternal)
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't set email for user", code)
|
||||
return
|
||||
}
|
||||
|
||||
// update User.Email from the token
|
||||
claims, _, err := s.authenticator.TokenService().Get(r)
|
||||
if err != nil {
|
||||
rest.SendErrorJSON(w, r, http.StatusForbidden, err, "failed to verify confirmation token", rest.ErrInternal)
|
||||
return
|
||||
}
|
||||
claims.User.Email = address
|
||||
if _, err = s.authenticator.TokenService().Set(w, claims); err != nil {
|
||||
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "failed to set token", rest.ErrInternal)
|
||||
return
|
||||
}
|
||||
render.JSON(w, r, R.JSON{"updated": true, "address": val})
|
||||
}
|
||||
|
||||
// POST/GET /email/unsubscribe?site=siteID&tkn=jwt - unsubscribe the user in token from email notifications
|
||||
func (s *private) emailUnsubscribeCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
tkn := r.URL.Query().Get("tkn")
|
||||
if tkn == "" {
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, errors.New("missing parameter"), "token parameter is required", rest.ErrInternal)
|
||||
return
|
||||
}
|
||||
locator := store.Locator{SiteID: r.URL.Query().Get("site")}
|
||||
|
||||
confClaims, err := s.authenticator.TokenService().Parse(tkn)
|
||||
if err != nil {
|
||||
rest.SendErrorJSON(w, r, http.StatusForbidden, err, "failed to verify confirmation token", rest.ErrInternal)
|
||||
return
|
||||
}
|
||||
|
||||
if s.authenticator.TokenService().IsExpired(confClaims) {
|
||||
rest.SendErrorJSON(w, r, http.StatusForbidden, errors.New("expired"), "failed to verify confirmation token", rest.ErrInternal)
|
||||
return
|
||||
}
|
||||
|
||||
elems := strings.Split(confClaims.Handshake.ID, "::")
|
||||
if len(elems) != 2 {
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, errors.New(confClaims.Handshake.ID), "invalid handshake token", rest.ErrInternal)
|
||||
return
|
||||
}
|
||||
userID := elems[0]
|
||||
address := elems[1]
|
||||
|
||||
existingAddress, err := s.dataService.GetUserEmail(locator, userID)
|
||||
if err != nil {
|
||||
log.Printf("[WARN] can't read email for %s, %v", userID, err)
|
||||
}
|
||||
if existingAddress == "" {
|
||||
rest.SendErrorJSON(w, r, http.StatusConflict, errors.New("user is not subscribed"), "user does not have active email subscription", rest.ErrInternal)
|
||||
return
|
||||
}
|
||||
if address != existingAddress {
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, errors.New("wrong email unsubscription"), "email address in request does not match known for this user", rest.ErrInternal)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[DEBUG] unsubscribe user %s", userID)
|
||||
|
||||
if err := s.dataService.DeleteUserDetail(locator, userID, engine.UserEmail); err != nil {
|
||||
code := parseError(err, rest.ErrInternal)
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't delete email for user", code)
|
||||
return
|
||||
}
|
||||
// clean User.Email from the token, if user has the token
|
||||
claims, _, err := s.authenticator.TokenService().Get(r)
|
||||
if err != nil {
|
||||
log.Printf("[DEBUG] unsubscribed user doesn't have valid JWT token to update %s, %v", userID, err)
|
||||
}
|
||||
if claims.User != nil && claims.User.Email != "" {
|
||||
claims.User.Email = ""
|
||||
if _, err = s.authenticator.TokenService().Set(w, claims); err != nil {
|
||||
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "failed to set token", rest.ErrInternal)
|
||||
return
|
||||
}
|
||||
}
|
||||
render.JSON(w, r, R.JSON{"unsubscribed": true})
|
||||
}
|
||||
|
||||
// DELETE /email?site=siteID - removes user's email
|
||||
func (s *private) deleteEmailCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
user := rest.MustGetUserInfo(r)
|
||||
locator := store.Locator{SiteID: r.URL.Query().Get("site")}
|
||||
log.Printf("[DEBUG] remove email for user %s", user.ID)
|
||||
|
||||
if err := s.dataService.DeleteUserDetail(locator, user.ID, engine.UserEmail); err != nil {
|
||||
code := parseError(err, rest.ErrInternal)
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't delete email for user", code)
|
||||
return
|
||||
}
|
||||
// clean User.Email from the token
|
||||
claims, _, err := s.authenticator.TokenService().Get(r)
|
||||
if err != nil {
|
||||
rest.SendErrorJSON(w, r, http.StatusForbidden, err, "failed to verify confirmation token", rest.ErrInternal)
|
||||
return
|
||||
}
|
||||
if claims.User.Email != "" {
|
||||
claims.User.Email = ""
|
||||
if _, err = s.authenticator.TokenService().Set(w, claims); err != nil {
|
||||
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "failed to set token", rest.ErrInternal)
|
||||
return
|
||||
}
|
||||
}
|
||||
render.JSON(w, r, R.JSON{"deleted": true})
|
||||
}
|
||||
|
||||
// GET /userdata?site=siteID - exports all data about the user as a json with user info and list of all comments
|
||||
func (s *private) userAllDataCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
siteID := r.URL.Query().Get("site")
|
||||
|
||||
@@ -15,11 +15,15 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dgrijalva/jwt-go"
|
||||
"github.com/go-chi/render"
|
||||
"github.com/go-pkgz/auth/token"
|
||||
"github.com/go-pkgz/lgr"
|
||||
R "github.com/go-pkgz/rest"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/umputun/remark/backend/app/notify"
|
||||
"github.com/umputun/remark/backend/app/store"
|
||||
"github.com/umputun/remark/backend/app/store/image"
|
||||
)
|
||||
@@ -450,6 +454,190 @@ func TestRest_Vote(t *testing.T) {
|
||||
assert.Equal(t, map[string]bool(nil), cr.Votes)
|
||||
}
|
||||
|
||||
func TestRest_Email(t *testing.T) {
|
||||
ts, srv, teardown := startupT(t)
|
||||
defer teardown()
|
||||
|
||||
// issue good token
|
||||
claims := token.Claims{
|
||||
Handshake: &token.Handshake{ID: "dev::good@example.com"},
|
||||
StandardClaims: jwt.StandardClaims{
|
||||
Audience: "remark42",
|
||||
ExpiresAt: time.Now().Add(10 * time.Minute).Unix(),
|
||||
NotBefore: time.Now().Add(-1 * time.Minute).Unix(),
|
||||
Issuer: "remark42",
|
||||
},
|
||||
}
|
||||
tkn, err := srv.Authenticator.TokenService().Token(claims)
|
||||
require.NoError(t, err)
|
||||
goodToken := tkn
|
||||
|
||||
var testData = []struct {
|
||||
description string
|
||||
url string
|
||||
method string
|
||||
responseCode int
|
||||
noAuth bool
|
||||
cookieEmail string
|
||||
}{
|
||||
{description: "issue delete request without auth", url: "/api/v1/email", method: http.MethodDelete, responseCode: http.StatusUnauthorized, noAuth: true},
|
||||
{description: "issue delete request without site_id", url: "/api/v1/email", method: http.MethodDelete, responseCode: http.StatusBadRequest},
|
||||
{description: "delete non-existent user email", url: "/api/v1/email?site=remark42", method: http.MethodDelete, responseCode: http.StatusOK},
|
||||
{description: "set user email, token not set", url: "/api/v1/email/confirm?site=remark42", method: http.MethodPost, responseCode: http.StatusBadRequest},
|
||||
{description: "send confirmation without address", url: "/api/v1/email/subscribe?site=remark42", method: http.MethodPost, responseCode: http.StatusBadRequest},
|
||||
{description: "send confirmation", url: "/api/v1/email/subscribe?site=remark42&address=good@example.com", method: http.MethodPost, responseCode: http.StatusOK},
|
||||
{description: "set user email, token is good", url: fmt.Sprintf("/api/v1/email/confirm?site=remark42&tkn=%s", goodToken), method: http.MethodPost, responseCode: http.StatusOK, cookieEmail: "good@example.com"},
|
||||
{description: "send confirmation with same address", url: "/api/v1/email/subscribe?site=remark42&address=good@example.com", method: http.MethodPost, responseCode: http.StatusConflict},
|
||||
{description: "get user email", url: "/api/v1/email?site=remark42", method: http.MethodGet, responseCode: http.StatusOK},
|
||||
{description: "delete user email", url: "/api/v1/email?site=remark42", method: http.MethodDelete, responseCode: http.StatusOK},
|
||||
{description: "send another confirmation", url: "/api/v1/email/subscribe?site=remark42&address=good@example.com", method: http.MethodPost, responseCode: http.StatusOK},
|
||||
}
|
||||
client := http.Client{}
|
||||
for _, x := range testData {
|
||||
t.Run(x.description, func(t *testing.T) {
|
||||
req, err := http.NewRequest(x.method, ts.URL+x.url, nil)
|
||||
require.NoError(t, err)
|
||||
if !x.noAuth {
|
||||
req.Header.Add("X-JWT", devToken)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
// read User.Email from the token in the cookie
|
||||
for _, c := range resp.Cookies() {
|
||||
if c.Name == "JWT" {
|
||||
claims, err := srv.Authenticator.TokenService().Parse(c.Value)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, x.cookieEmail, claims.User.Email, "cookie email check failed")
|
||||
}
|
||||
}
|
||||
assert.Equal(t, x.responseCode, resp.StatusCode, string(body))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRest_EmailNotification(t *testing.T) {
|
||||
ts, srv, teardown := startupT(t)
|
||||
defer teardown()
|
||||
|
||||
mockDestination := ¬ify.MockDest{}
|
||||
srv.privRest.notifyService = notify.NewService(srv.DataService, 1, mockDestination)
|
||||
|
||||
client := http.Client{}
|
||||
|
||||
// create new comment from dev user
|
||||
req, err := http.NewRequest("POST", ts.URL+"/api/v1/comment", strings.NewReader(
|
||||
`{"text": "test 123",
|
||||
"user": {"name": "dev::good@example.com"},
|
||||
"locator":{"url": "https://radio-t.com/blah1",
|
||||
"site": "remark42"}}`))
|
||||
assert.Nil(t, err)
|
||||
req.Header.Add("X-JWT", devToken)
|
||||
resp, err := client.Do(req)
|
||||
assert.Nil(t, err)
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode, string(body))
|
||||
parentComment := store.Comment{}
|
||||
require.NoError(t, render.DecodeJSON(strings.NewReader(string(body)), &parentComment))
|
||||
// wait for mock notification Submit to kick off
|
||||
time.Sleep(time.Millisecond * 5)
|
||||
require.Equal(t, 1, len(mockDestination.Get()))
|
||||
assert.Equal(t, "", mockDestination.Get()[0].Email)
|
||||
|
||||
// create child comment from another user, no email notification expected
|
||||
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment", strings.NewReader(fmt.Sprintf(
|
||||
`{"text": "test 456",
|
||||
"pid": "%s",
|
||||
"user": {"name": "other_user"},
|
||||
"locator":{"url": "https://radio-t.com/blah1",
|
||||
"site": "remark42"}}`, parentComment.ID)))
|
||||
assert.Nil(t, err)
|
||||
req.Header.Add("X-JWT", devToken)
|
||||
resp, err = client.Do(req)
|
||||
assert.Nil(t, err)
|
||||
body, err = ioutil.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode, string(body))
|
||||
// wait for mock notification Submit to kick off
|
||||
time.Sleep(time.Millisecond * 5)
|
||||
require.Equal(t, 2, len(mockDestination.Get()))
|
||||
assert.Empty(t, mockDestination.Get()[1].Email)
|
||||
|
||||
// send confirmation token for email
|
||||
req, err = http.NewRequest(http.MethodPost, ts.URL+"/api/v1/email/subscribe?site=remark42&address=good@example.com", nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Add("X-JWT", devToken)
|
||||
resp, err = client.Do(req)
|
||||
require.NoError(t, err)
|
||||
body, err = ioutil.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, string(body))
|
||||
// wait for mock notification Submit to kick off
|
||||
time.Sleep(time.Millisecond * 5)
|
||||
require.Equal(t, 3, len(mockDestination.Get()))
|
||||
require.NotEmpty(t, mockDestination.Get()[2].Verification)
|
||||
verificationToken := mockDestination.Get()[2].Verification.Token
|
||||
|
||||
// verify email
|
||||
req, err = http.NewRequest(http.MethodPost, ts.URL+fmt.Sprintf("/api/v1/email/confirm?site=remark42&tkn=%s", verificationToken), nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Add("X-JWT", devToken)
|
||||
resp, err = client.Do(req)
|
||||
require.NoError(t, err)
|
||||
body, err = ioutil.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, string(body))
|
||||
|
||||
// create child comment from another user, email notification expected
|
||||
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment", strings.NewReader(fmt.Sprintf(
|
||||
`{"text": "test 789",
|
||||
"pid": "%s",
|
||||
"user": {"name": "other_user"},
|
||||
"locator":{"url": "https://radio-t.com/blah1",
|
||||
"site": "remark42"}}`, parentComment.ID)))
|
||||
assert.Nil(t, err)
|
||||
req.Header.Add("X-JWT", devToken)
|
||||
resp, err = client.Do(req)
|
||||
assert.Nil(t, err)
|
||||
body, err = ioutil.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode, string(body))
|
||||
// wait for mock notification Submit to kick off
|
||||
time.Sleep(time.Millisecond * 5)
|
||||
require.Equal(t, 4, len(mockDestination.Get()))
|
||||
assert.Equal(t, "good@example.com", mockDestination.Get()[3].Email)
|
||||
|
||||
// delete user's email
|
||||
req, err = http.NewRequest(http.MethodDelete, ts.URL+"/api/v1/email?site=remark42", nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Add("X-JWT", devToken)
|
||||
resp, err = client.Do(req)
|
||||
require.NoError(t, err)
|
||||
body, err = ioutil.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode, string(body))
|
||||
|
||||
// create child comment from another user, no email notification expected
|
||||
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment", strings.NewReader(
|
||||
`{"text": "test 321",
|
||||
"user": {"name": "other_user"},
|
||||
"locator":{"url": "https://radio-t.com/blah1",
|
||||
"site": "remark42"}}`))
|
||||
assert.Nil(t, err)
|
||||
req.Header.Add("X-JWT", devToken)
|
||||
resp, err = client.Do(req)
|
||||
assert.Nil(t, err)
|
||||
body, err = ioutil.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode, string(body))
|
||||
// wait for mock notification Submit to kick off
|
||||
time.Sleep(time.Millisecond * 5)
|
||||
require.Equal(t, 5, len(mockDestination.Get()))
|
||||
assert.Empty(t, mockDestination.Get()[4].Email)
|
||||
}
|
||||
|
||||
func TestRest_UserAllData(t *testing.T) {
|
||||
ts, srv, teardown := startupT(t)
|
||||
defer teardown()
|
||||
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/umputun/remark/backend/app/migrator"
|
||||
"github.com/umputun/remark/backend/app/notify"
|
||||
"github.com/umputun/remark/backend/app/rest"
|
||||
"github.com/umputun/remark/backend/app/rest/proxy"
|
||||
"github.com/umputun/remark/backend/app/store"
|
||||
@@ -346,7 +347,8 @@ func startupT(t *testing.T) (ts *httptest.Server, srv *Rest, teardown func()) {
|
||||
TimeOut: 5 * time.Second,
|
||||
MaxActive: 100,
|
||||
},
|
||||
EmojiEnabled: true,
|
||||
NotifyService: notify.NopService,
|
||||
EmojiEnabled: true,
|
||||
}
|
||||
srv.ScoreThresholds.Low, srv.ScoreThresholds.Critical = -5, -10
|
||||
|
||||
|
||||
@@ -159,6 +159,48 @@ func (s *DataStore) Put(locator store.Locator, comment store.Comment) error {
|
||||
return s.Engine.Update(comment)
|
||||
}
|
||||
|
||||
// GetUserEmail gets user email
|
||||
func (s *DataStore) GetUserEmail(locator store.Locator, userID string) (string, error) {
|
||||
res, err := s.Engine.UserDetail(engine.UserDetailRequest{
|
||||
Detail: engine.UserEmail,
|
||||
Locator: locator,
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(res) == 1 {
|
||||
return res[0].Email, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// SetUserEmail sets user email
|
||||
func (s *DataStore) SetUserEmail(locator store.Locator, userID string, value string) (string, error) {
|
||||
res, err := s.Engine.UserDetail(engine.UserDetailRequest{
|
||||
Detail: engine.UserEmail,
|
||||
Locator: locator,
|
||||
UserID: userID,
|
||||
Update: value,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(res) == 1 {
|
||||
return res[0].Email, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// DeleteUserDetail deletes user detail
|
||||
func (s *DataStore) DeleteUserDetail(locator store.Locator, userID string, detail engine.UserDetail) error {
|
||||
return s.Engine.Delete(engine.DeleteRequest{
|
||||
Locator: locator,
|
||||
UserID: userID,
|
||||
UserDetail: detail,
|
||||
})
|
||||
}
|
||||
|
||||
// submitImages initiated delayed commit of all images from the comment uploaded to remark42
|
||||
func (s *DataStore) submitImages(comment store.Comment) {
|
||||
|
||||
|
||||
+30
-15
@@ -5,9 +5,9 @@ GET {{host}}/api/v1/find?site={{site}}&sort=-time&format=tree&url={{url}}
|
||||
### find request with plain
|
||||
GET {{host}}/api/v1/find?site={{site}}&sort=-controversy&format=plain&url={{url}}
|
||||
|
||||
### find request with plain. dev token for secret=secret, not admin
|
||||
GET http://127.0.0.1:8080/api/v1/find?site={{site}}&sort=-controversy&format=plain&url={{url}}
|
||||
X-JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcms0MiIsImV4cCI6Mzc4OTE5MTgyMiwianRpIjoicmFuZG9tIGlkIiwiaXNzIjoicmVtYXJrNDIiLCJuYmYiOjE1MjE4ODQyMjIsInVzZXIiOnsibmFtZSI6ImRldmVsb3BlciBvbmUiLCJpZCI6ImRldiIsInBpY3R1cmUiOiJodHRwOi8vZXhhbXBsZS5jb20vcGljLnBuZyIsImlwIjoiMTI3LjAuMC4xIiwiZW1haWwiOiJtZUBleGFtcGxlLmNvbSJ9fQ.aKUAXiZxXypgV7m1wEOgUcyPOvUDXHDi3A06YWKbcLg
|
||||
### find request with plain. dev token for secret=12345, not admin
|
||||
GET {{host}}/api/v1/find?site={{site}}&sort=-controversy&format=plain&url={{url}}
|
||||
X-JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcmsiLCJleHAiOjE5NzYwNTY3NTYsImp0aSI6IjJlOGJmMTE5OTI0MjQxMDRjYjFhZGRlODllMWYwNGFiMTg4YWZjMzQiLCJpYXQiOjE1NzYwNTY0NTYsImlzcyI6InJlbWFyazQyIiwidXNlciI6eyJuYW1lIjoiZGV2X3VzZXIiLCJpZCI6ImRldl91c2VyIiwicGljdHVyZSI6Imh0dHA6Ly8xMjcuMC4wLjE6ODA4MC9hcGkvdjEvYXZhdGFyL2NjZmEyYWJkMDE2Njc2MDViNGUxZmM0ZmNiOTFiMWUxYWYzMjMyNDAuaW1hZ2UiLCJhdHRycyI6eyJhZG1pbiI6dHJ1ZSwiYmxvY2tlZCI6ZmFsc2V9fX0.6Qt5s2enBMRC-Jmsua01yViVYI95Dx6BPBMaNjj36d4
|
||||
|
||||
### last 50 comments
|
||||
GET {{host}}/api/v1/last/50?site={{site}}
|
||||
@@ -50,15 +50,15 @@ Content-Type: application/json
|
||||
|
||||
{
|
||||
"text": "edit comment blah http://radio-t.com 12345",
|
||||
"summary": "fix blah"
|
||||
"summary": "fix blah"
|
||||
}
|
||||
|
||||
### pin comment
|
||||
PUT {{host}}/api/v1/admin/pin/3665976683?site={{site}}&url={{url}}&pin=1
|
||||
|
||||
### vote for comment
|
||||
PUT http://127.0.0.1:8080/api/v1/vote/8a8c0b80-0d0a-41c3-84ad-f4034704e827?site={{site}}&url={{url}}&vote=-1
|
||||
X-JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcms0MiIsImV4cCI6Mzc4OTE5MTgyMiwianRpIjoicmFuZG9tIGlkIiwiaXNzIjoicmVtYXJrNDIiLCJuYmYiOjE1MjE4ODQyMjIsInVzZXIiOnsibmFtZSI6ImRldmVsb3BlciBvbmUiLCJpZCI6ImRldiIsInBpY3R1cmUiOiJodHRwOi8vZXhhbXBsZS5jb20vcGljLnBuZyIsImlwIjoiMTI3LjAuMC4xIiwiZW1haWwiOiJtZUBleGFtcGxlLmNvbSJ9fQ.aKUAXiZxXypgV7m1wEOgUcyPOvUDXHDi3A06YWKbcLg
|
||||
PUT {{host}}/api/v1/vote/8a8c0b80-0d0a-41c3-84ad-f4034704e827?site={{site}}&url={{url}}&vote=-1
|
||||
X-JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcmsiLCJleHAiOjE5NzYwNTY3NTYsImp0aSI6IjJlOGJmMTE5OTI0MjQxMDRjYjFhZGRlODllMWYwNGFiMTg4YWZjMzQiLCJpYXQiOjE1NzYwNTY0NTYsImlzcyI6InJlbWFyazQyIiwidXNlciI6eyJuYW1lIjoiZGV2X3VzZXIiLCJpZCI6ImRldl91c2VyIiwicGljdHVyZSI6Imh0dHA6Ly8xMjcuMC4wLjE6ODA4MC9hcGkvdjEvYXZhdGFyL2NjZmEyYWJkMDE2Njc2MDViNGUxZmM0ZmNiOTFiMWUxYWYzMjMyNDAuaW1hZ2UiLCJhdHRycyI6eyJhZG1pbiI6dHJ1ZSwiYmxvY2tlZCI6ZmFsc2V9fX0.6Qt5s2enBMRC-Jmsua01yViVYI95Dx6BPBMaNjj36d4
|
||||
|
||||
### get user info
|
||||
GET {{host}}/api/v1/user
|
||||
@@ -83,9 +83,9 @@ POST {{host}}/api/v1/counts?site={{site}}
|
||||
Content-Type: application/json
|
||||
|
||||
[
|
||||
"https://radio-t.com/p/2017/12/02/podcast-574/",
|
||||
"https://radio-t.com/p/2017/12/09/podcast-575/",
|
||||
"{{url}}"
|
||||
"https://radio-t.com/p/2017/12/02/podcast-574/",
|
||||
"https://radio-t.com/p/2017/12/09/podcast-575/",
|
||||
"{{url}}"
|
||||
]
|
||||
|
||||
### list commented posts
|
||||
@@ -104,7 +104,7 @@ GET {{host}}/api/v1/admin/blocked?site={{site}}
|
||||
DELETE {{host}}/api/v1/admin/comment/3665976683?site={{site}}&url={{url}}
|
||||
|
||||
### get post info
|
||||
GET {{host}}/api/v1/info?site={{site}}&url={{url}
|
||||
GET {{host}}/api/v1/info?site={{site}}&url={{url}}
|
||||
|
||||
### post rss
|
||||
GET {{host}}/api/v1/rss/post?site={{site}}&url={{url}}
|
||||
@@ -118,17 +118,32 @@ GET {{host}}/api/v1/rss/reply?site={{site}}&user={{user}}
|
||||
### get default avatar
|
||||
GET {{host}}/api/v1/avatar/blah
|
||||
|
||||
### get config
|
||||
GET {{host}}/api/v1/config?site={{site}}
|
||||
### send confirmation token for current user to specified email. auth token for dev user for secret=12345.
|
||||
POST {{host}}/api/v1/email/subscribe?site={{site}}&address={{email}}
|
||||
X-JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcmsiLCJleHAiOjE5NzYwNTY3NTYsImp0aSI6IjJlOGJmMTE5OTI0MjQxMDRjYjFhZGRlODllMWYwNGFiMTg4YWZjMzQiLCJpYXQiOjE1NzYwNTY0NTYsImlzcyI6InJlbWFyazQyIiwidXNlciI6eyJuYW1lIjoiZGV2X3VzZXIiLCJpZCI6ImRldl91c2VyIiwicGljdHVyZSI6Imh0dHA6Ly8xMjcuMC4wLjE6ODA4MC9hcGkvdjEvYXZhdGFyL2NjZmEyYWJkMDE2Njc2MDViNGUxZmM0ZmNiOTFiMWUxYWYzMjMyNDAuaW1hZ2UiLCJhdHRycyI6eyJhZG1pbiI6dHJ1ZSwiYmxvY2tlZCI6ZmFsc2V9fX0.6Qt5s2enBMRC-Jmsua01yViVYI95Dx6BPBMaNjj36d4
|
||||
|
||||
### deleteme (user's request). dev token for secret=secret, not admin
|
||||
### add email for notifications for current user via token from email. auth token for dev user for secret=12345.
|
||||
POST {{host}}/api/v1/email/confirm?site={{site}}&tkn={{token}}
|
||||
X-JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcmsiLCJleHAiOjE5NzYwNTY3NTYsImp0aSI6IjJlOGJmMTE5OTI0MjQxMDRjYjFhZGRlODllMWYwNGFiMTg4YWZjMzQiLCJpYXQiOjE1NzYwNTY0NTYsImlzcyI6InJlbWFyazQyIiwidXNlciI6eyJuYW1lIjoiZGV2X3VzZXIiLCJpZCI6ImRldl91c2VyIiwicGljdHVyZSI6Imh0dHA6Ly8xMjcuMC4wLjE6ODA4MC9hcGkvdjEvYXZhdGFyL2NjZmEyYWJkMDE2Njc2MDViNGUxZmM0ZmNiOTFiMWUxYWYzMjMyNDAuaW1hZ2UiLCJhdHRycyI6eyJhZG1pbiI6dHJ1ZSwiYmxvY2tlZCI6ZmFsc2V9fX0.6Qt5s2enBMRC-Jmsua01yViVYI95Dx6BPBMaNjj36d4
|
||||
|
||||
### get current user email. auth token for dev user for secret=12345.
|
||||
GET {{host}}/api/v1/email?site={{site}}
|
||||
X-JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcmsiLCJleHAiOjE5NzYwNTY3NTYsImp0aSI6IjJlOGJmMTE5OTI0MjQxMDRjYjFhZGRlODllMWYwNGFiMTg4YWZjMzQiLCJpYXQiOjE1NzYwNTY0NTYsImlzcyI6InJlbWFyazQyIiwidXNlciI6eyJuYW1lIjoiZGV2X3VzZXIiLCJpZCI6ImRldl91c2VyIiwicGljdHVyZSI6Imh0dHA6Ly8xMjcuMC4wLjE6ODA4MC9hcGkvdjEvYXZhdGFyL2NjZmEyYWJkMDE2Njc2MDViNGUxZmM0ZmNiOTFiMWUxYWYzMjMyNDAuaW1hZ2UiLCJhdHRycyI6eyJhZG1pbiI6dHJ1ZSwiYmxvY2tlZCI6ZmFsc2V9fX0.6Qt5s2enBMRC-Jmsua01yViVYI95Dx6BPBMaNjj36d4
|
||||
|
||||
### delete current user email. auth token for dev user for secret=12345.
|
||||
DELETE {{host}}/api/v1/email?site={{site}}
|
||||
X-JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcmsiLCJleHAiOjE5NzYwNTY3NTYsImp0aSI6IjJlOGJmMTE5OTI0MjQxMDRjYjFhZGRlODllMWYwNGFiMTg4YWZjMzQiLCJpYXQiOjE1NzYwNTY0NTYsImlzcyI6InJlbWFyazQyIiwidXNlciI6eyJuYW1lIjoiZGV2X3VzZXIiLCJpZCI6ImRldl91c2VyIiwicGljdHVyZSI6Imh0dHA6Ly8xMjcuMC4wLjE6ODA4MC9hcGkvdjEvYXZhdGFyL2NjZmEyYWJkMDE2Njc2MDViNGUxZmM0ZmNiOTFiMWUxYWYzMjMyNDAuaW1hZ2UiLCJhdHRycyI6eyJhZG1pbiI6dHJ1ZSwiYmxvY2tlZCI6ZmFsc2V9fX0.6Qt5s2enBMRC-Jmsua01yViVYI95Dx6BPBMaNjj36d4
|
||||
|
||||
### get config
|
||||
GET {{host}}/api/v1/config?site={{site}}
|
||||
|
||||
### deleteme (user's request). dev token for secret=12345, not admin
|
||||
POST {{host}}/api/v1/deleteme?site_id={{site}}
|
||||
X-JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcms0MiIsImV4cCI6Mzc4OTE5MTgyMiwianRpIjoicmFuZG9tIGlkIiwiaXNzIjoicmVtYXJrNDIiLCJuYmYiOjE1MjE4ODQyMjIsInVzZXIiOnsibmFtZSI6ImRldmVsb3BlciBvbmUiLCJpZCI6ImRldiIsInBpY3R1cmUiOiJodHRwOi8vZXhhbXBsZS5jb20vcGljLnBuZyIsImlwIjoiMTI3LjAuMC4xIiwiZW1haWwiOiJtZUBleGFtcGxlLmNvbSJ9fQ.aKUAXiZxXypgV7m1wEOgUcyPOvUDXHDi3A06YWKbcLg
|
||||
X-JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcmsiLCJleHAiOjE5NzYwNTY3NTYsImp0aSI6IjJlOGJmMTE5OTI0MjQxMDRjYjFhZGRlODllMWYwNGFiMTg4YWZjMzQiLCJpYXQiOjE1NzYwNTY0NTYsImlzcyI6InJlbWFyazQyIiwidXNlciI6eyJuYW1lIjoiZGV2X3VzZXIiLCJpZCI6ImRldl91c2VyIiwicGljdHVyZSI6Imh0dHA6Ly8xMjcuMC4wLjE6ODA4MC9hcGkvdjEvYXZhdGFyL2NjZmEyYWJkMDE2Njc2MDViNGUxZmM0ZmNiOTFiMWUxYWYzMjMyNDAuaW1hZ2UiLCJhdHRycyI6eyJhZG1pbiI6dHJ1ZSwiYmxvY2tlZCI6ZmFsc2V9fX0.6Qt5s2enBMRC-Jmsua01yViVYI95Dx6BPBMaNjj36d4
|
||||
|
||||
### deletme, admin. admin token for secret=secret
|
||||
GET {{host}}/api/v1/admin/deleteme?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1NjU2NjI4MDYsImlzcyI6InJlbWFyazQyIiwibmJmIjoxNTU3NzEzOTQ2LCJ1c2VyIjp7Im5hbWUiOiJkZXZlbG9wZXIgb25lIiwiaWQiOiJkZXYiLCJwaWN0dXJlIjoiIiwiYXR0cnMiOnsiYWRtaW4iOmZhbHNlLCJibG9ja2VkIjpmYWxzZSwiZGVsZXRlX21lIjp0cnVlfX19.qmnsQt_jilHzoauA9D7t1m3w69qvAJsZkuKVPWhpdik
|
||||
X-JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcmsiLCJleHAiOjE2NTc3MDQ5NzksImp0aSI6Ijk4ZDNhOGFkMGNjZWU5M2Q1MWYwYjJiOTY1ZjU2YmE2NmJkNmZiNzYiLCJpYXQiOjE1NTc3MDQ2NzksImlzcyI6InJlbWFyazQyIiwidXNlciI6eyJuYW1lIjoiZGV2X3VzZXIiLCJpZCI6ImRldl91c2VyIiwicGljdHVyZSI6Imh0dHA6Ly8xMjcuMC4wLjE6ODA4MC9hcGkvdjEvYXZhdGFyL2NjZmEyYWJkMDE2Njc2MDViNGUxZmM0ZmNiOTFiMWUxYWYzMjMyNDAuaW1hZ2UiLCJhdHRycyI6eyJhZG1pbiI6dHJ1ZSwiYmxvY2tlZCI6ZmFsc2V9fX0.YIt9Zq3n0O8PMkR78pGAqyHI0exCU0vLKjcHgcXfLFw
|
||||
|
||||
|
||||
### ping
|
||||
GET {{host}}/ping
|
||||
|
||||
@@ -49,6 +49,12 @@ services:
|
||||
- NOTIFY_TYPE
|
||||
- NOTIFY_TELEGRAM_TOKEN
|
||||
- NOTIFY_TELEGRAM_CHAN
|
||||
- NOTIFY_EMAIL_HOST
|
||||
- NOTIFY_EMAIL_USERNAME
|
||||
- NOTIFY_EMAIL_PASSWORD
|
||||
- NOTIFY_EMAIL_FROM
|
||||
- NOTIFY_EMAIL_PORT
|
||||
- NOTIFY_EMAIL_TLS
|
||||
- EMOJI=true
|
||||
- VOTES_IP=true
|
||||
- AUTH_EMAIL_ENABLE=true
|
||||
|
||||
@@ -339,8 +339,8 @@
|
||||
1. One<br>
|
||||
2. Two<br>
|
||||
3. Three<br>
|
||||
3.1. Item 3.1<br>
|
||||
3.2. Item 3.2<br>
|
||||
3.1. Item 3.1<br>
|
||||
3.2. Item 3.2<br>
|
||||
</p>
|
||||
|
||||
<p>If you want bullet point</p>
|
||||
@@ -348,8 +348,8 @@
|
||||
<p>
|
||||
* Item 1<br>
|
||||
* Item 2<br>
|
||||
* Item 2a<br>
|
||||
* Item 2b<br>
|
||||
* Item 2a<br>
|
||||
* Item 2b<br>
|
||||
</p>
|
||||
|
||||
<h2 id="Images">Images</h2>
|
||||
|
||||
Reference in New Issue
Block a user