diff --git a/README.md b/README.md
index da8a34bc..76e47956 100644
--- a/README.md
+++ b/README.md
@@ -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`.
diff --git a/backend/app/cmd/server.go b/backend/app/cmd/server.go
index 04b465a4..2d7710ce 100644
--- a/backend/app/cmd/server.go
+++ b/backend/app/cmd/server.go
@@ -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 = `
Remark42
-
Confirmation for {{.User}} on site {{.Site}}
+
Confirmation for {{.User}} on site {{.Site}}
TOKEN
-
Copy and paste this text into “token” field on comments page
+
Copy and paste this text into “token” field on comments page
{{.Token}}
Sent to {{.Address}}
@@ -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,
diff --git a/backend/app/cmd/server_test.go b/backend/app/cmd/server_test.go
index f8dae9df..b5dd8e00 100644
--- a/backend/app/cmd/server_test.go
+++ b/backend/app/cmd/server_test.go
@@ -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)
diff --git a/backend/app/notify/email.go b/backend/app/notify/email.go
index 158b7bdb..9bb8f8cb 100644
--- a/backend/app/notify/email.go
+++ b/backend/app/notify/email.go
@@ -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}}
-
-↦
{{if .PostTitle}}{{.PostTitle}}{{else}}original comment{{end}}
+ defaultEmailTemplate = `
+
+
+
+
+
+
+
+
+
Remark42
+
+
New reply from {{.CommentUser}} on your comment{{if .PostTitle}} to "{{.PostTitle}}"{{end}}
+
{{.CommentText}}
+
Sent to {{.Email}} for {{.ParentUser}}
Unsubscribe
+
+
+
+
`
- defaultEmailVerificationTemplate = `Confirmation for {{.User}} {{.Email}}, site {{.Site}}
-
-Token: {{.Token}}
+ defaultEmailVerificationTemplate = `
+
+
+
+
+
+
+
+
Remark42
+
Confirmation for {{.User}} on site {{.Site}}
+
+
TOKEN
+
Copy and paste this text into “token” field on comments page
+
{{.Token}}
+
+
Sent to {{.Email}}
+
+
+
`
)
-// 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)
}
diff --git a/backend/app/notify/email_test.go b/backend/app/notify/email_test.go
index 7c977a79..f137b914 100644
--- a/backend/app/notify/email_test.go
+++ b/backend/app/notify/email_test.go
@@ -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
-
-
-
-↦
test_title
-`
- 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:
+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
}
diff --git a/backend/app/notify/notify.go b/backend/app/notify/notify.go
index 7d07da8c..e7f80a80 100644
--- a/backend/app/notify/notify.go
+++ b/backend/app/notify/notify.go
@@ -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 {
diff --git a/backend/app/notify/notify_mock.go b/backend/app/notify/notify_mock.go
new file mode 100644
index 00000000..43588d67
--- /dev/null
+++ b/backend/app/notify/notify_mock.go
@@ -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) }
diff --git a/backend/app/notify/notify_test.go b/backend/app/notify/notify_test.go
index 3927d3a7..0f53afbc 100644
--- a/backend/app/notify/notify_test.go
+++ b/backend/app/notify/notify_test.go
@@ -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")
+}
diff --git a/backend/app/rest/api/rest.go b/backend/app/rest/api/rest.go
index 20d622e9..55072b4b 100644
--- a/backend/app/rest/api/rest.go
+++ b/backend/app/rest/api/rest.go
@@ -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
diff --git a/backend/app/rest/api/rest_private.go b/backend/app/rest/api/rest_private.go
index 2362139a..782b241f 100644
--- a/backend/app/rest/api/rest_private.go
+++ b/backend/app/rest/api/rest_private.go
@@ -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")
diff --git a/backend/app/rest/api/rest_private_test.go b/backend/app/rest/api/rest_private_test.go
index cb16a42b..e309e31e 100644
--- a/backend/app/rest/api/rest_private_test.go
+++ b/backend/app/rest/api/rest_private_test.go
@@ -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()
diff --git a/backend/app/rest/api/rest_test.go b/backend/app/rest/api/rest_test.go
index 99d531e8..ee2dc6ac 100644
--- a/backend/app/rest/api/rest_test.go
+++ b/backend/app/rest/api/rest_test.go
@@ -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
diff --git a/backend/app/store/service/service.go b/backend/app/store/service/service.go
index 2defb9bb..afe815c9 100644
--- a/backend/app/store/service/service.go
+++ b/backend/app/store/service/service.go
@@ -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) {
diff --git a/backend/remark.rest b/backend/remark.rest
index 5136091f..1a34ee08 100644
--- a/backend/remark.rest
+++ b/backend/remark.rest
@@ -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
diff --git a/compose-dev-backend.yml b/compose-dev-backend.yml
index 4c2e806a..3397fc98 100644
--- a/compose-dev-backend.yml
+++ b/compose-dev-backend.yml
@@ -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
diff --git a/frontend/markdown-help.html b/frontend/markdown-help.html
index 34a6903f..affd981c 100644
--- a/frontend/markdown-help.html
+++ b/frontend/markdown-help.html
@@ -339,8 +339,8 @@
1. One
2. Two
3. Three
- 3.1. Item 3.1
- 3.2. Item 3.2
+ 3.1. Item 3.1
+ 3.2. Item 3.2
If you want bullet point
@@ -348,8 +348,8 @@
* Item 1
* Item 2
- * Item 2a
- * Item 2b
+ * Item 2a
+ * Item 2b
Images