mirror of https://github.com/writeas/writefreely
This adds beginning email subscription functionality, with only MySQL support, Mailgun support, and incomplete support for private instances. It includes database changes, so run: writefreely db migrate to use this feature. Ref T856pull/478/head
parent
e983c4527f
commit
2ea235f0c4
@ -0,0 +1,466 @@ |
|||||||
|
/* |
||||||
|
* Copyright © 2019-2021 A Bunch Tell LLC. |
||||||
|
* |
||||||
|
* This file is part of WriteFreely. |
||||||
|
* |
||||||
|
* WriteFreely is free software: you can redistribute it and/or modify |
||||||
|
* it under the terms of the GNU Affero General Public License, included |
||||||
|
* in the LICENSE file in this source code package. |
||||||
|
*/ |
||||||
|
|
||||||
|
package writefreely |
||||||
|
|
||||||
|
import ( |
||||||
|
"database/sql" |
||||||
|
"encoding/json" |
||||||
|
"fmt" |
||||||
|
"html/template" |
||||||
|
"net/http" |
||||||
|
"strings" |
||||||
|
"time" |
||||||
|
|
||||||
|
"github.com/aymerick/douceur/inliner" |
||||||
|
"github.com/gorilla/mux" |
||||||
|
"github.com/mailgun/mailgun-go" |
||||||
|
stripmd "github.com/writeas/go-strip-markdown/v2" |
||||||
|
"github.com/writeas/impart" |
||||||
|
"github.com/writeas/web-core/data" |
||||||
|
"github.com/writeas/web-core/log" |
||||||
|
"github.com/writefreely/writefreely/key" |
||||||
|
"github.com/writefreely/writefreely/spam" |
||||||
|
) |
||||||
|
|
||||||
|
const ( |
||||||
|
emailSendDelay = 15 |
||||||
|
) |
||||||
|
|
||||||
|
type ( |
||||||
|
SubmittedSubscription struct { |
||||||
|
CollAlias string |
||||||
|
UserID int64 |
||||||
|
|
||||||
|
Email string `schema:"email" json:"email"` |
||||||
|
Web bool `schema:"web" json:"web"` |
||||||
|
Slug string `schema:"slug" json:"slug"` |
||||||
|
From string `schema:"from" json:"from"` |
||||||
|
} |
||||||
|
|
||||||
|
EmailSubscriber struct { |
||||||
|
ID string |
||||||
|
CollID int64 |
||||||
|
UserID sql.NullInt64 |
||||||
|
Email sql.NullString |
||||||
|
Subscribed time.Time |
||||||
|
Token string |
||||||
|
Confirmed bool |
||||||
|
AllowExport bool |
||||||
|
acctEmail sql.NullString |
||||||
|
} |
||||||
|
) |
||||||
|
|
||||||
|
func (es *EmailSubscriber) FinalEmail(keys *key.Keychain) string { |
||||||
|
if !es.UserID.Valid || es.Email.Valid { |
||||||
|
return es.Email.String |
||||||
|
} |
||||||
|
|
||||||
|
decEmail, err := data.Decrypt(keys.EmailKey, []byte(es.acctEmail.String)) |
||||||
|
if err != nil { |
||||||
|
log.Error("Error decrypting user email: %v", err) |
||||||
|
return "" |
||||||
|
} |
||||||
|
return string(decEmail) |
||||||
|
} |
||||||
|
|
||||||
|
func (es *EmailSubscriber) SubscribedFriendly() string { |
||||||
|
return es.Subscribed.Format("January 2, 2006") |
||||||
|
} |
||||||
|
|
||||||
|
func handleCreateEmailSubscription(app *App, w http.ResponseWriter, r *http.Request) error { |
||||||
|
reqJSON := IsJSON(r) |
||||||
|
vars := mux.Vars(r) |
||||||
|
var err error |
||||||
|
|
||||||
|
ss := SubmittedSubscription{ |
||||||
|
CollAlias: vars["alias"], |
||||||
|
} |
||||||
|
u := getUserSession(app, r) |
||||||
|
if u != nil { |
||||||
|
ss.UserID = u.ID |
||||||
|
} |
||||||
|
if reqJSON { |
||||||
|
// Decode JSON request
|
||||||
|
decoder := json.NewDecoder(r.Body) |
||||||
|
err = decoder.Decode(&ss) |
||||||
|
if err != nil { |
||||||
|
log.Error("Couldn't parse new subscription JSON request: %v\n", err) |
||||||
|
return ErrBadJSON |
||||||
|
} |
||||||
|
} else { |
||||||
|
err = r.ParseForm() |
||||||
|
if err != nil { |
||||||
|
log.Error("Couldn't parse new subscription form request: %v\n", err) |
||||||
|
return ErrBadFormData |
||||||
|
} |
||||||
|
|
||||||
|
err = app.formDecoder.Decode(&ss, r.PostForm) |
||||||
|
if err != nil { |
||||||
|
log.Error("Continuing, but error decoding new subscription form request: %v\n", err) |
||||||
|
//return ErrBadFormData
|
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
c, err := app.db.GetCollection(ss.CollAlias) |
||||||
|
if err != nil { |
||||||
|
log.Error("getCollection: %s", err) |
||||||
|
return err |
||||||
|
} |
||||||
|
c.hostName = app.cfg.App.Host |
||||||
|
|
||||||
|
from := c.CanonicalURL() |
||||||
|
isAuthorBanned, err := app.db.IsUserSilenced(c.OwnerID) |
||||||
|
if isAuthorBanned { |
||||||
|
log.Info("Author is silenced, so subscription is blocked.") |
||||||
|
return impart.HTTPError{http.StatusFound, from} |
||||||
|
} |
||||||
|
|
||||||
|
if ss.Web { |
||||||
|
if u != nil && u.ID == c.OwnerID { |
||||||
|
from = "/" + c.Alias + "/" |
||||||
|
} |
||||||
|
from += ss.Slug |
||||||
|
} |
||||||
|
|
||||||
|
if r.FormValue(spam.HoneypotFieldName()) != "" || r.FormValue("fake_password") != "" { |
||||||
|
log.Info("Honeypot field was filled out! Not subscribing.") |
||||||
|
return impart.HTTPError{http.StatusFound, from} |
||||||
|
} |
||||||
|
|
||||||
|
if ss.Email == "" && ss.UserID < 1 { |
||||||
|
log.Info("No subscriber data. Not subscribing.") |
||||||
|
return impart.HTTPError{http.StatusFound, from} |
||||||
|
} |
||||||
|
|
||||||
|
// Do email validation
|
||||||
|
// TODO: move this to an AJAX call before submitting email address, so we can immediately show errors to user
|
||||||
|
/* |
||||||
|
err := validate(ss.Email) |
||||||
|
if err != nil { |
||||||
|
addSessionFlash(w, r, err.Error(), nil) |
||||||
|
return impart.HTTPError{http.StatusFound, from} |
||||||
|
} |
||||||
|
*/ |
||||||
|
|
||||||
|
confirmed := app.db.IsSubscriberConfirmed(ss.Email) |
||||||
|
es, err := app.db.AddEmailSubscription(c.ID, ss.UserID, ss.Email, confirmed) |
||||||
|
if err != nil { |
||||||
|
log.Error("addEmailSubscription: %s", err) |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
// Send confirmation email if needed
|
||||||
|
if !confirmed { |
||||||
|
sendSubConfirmEmail(app, c, ss.Email, es.ID, es.Token) |
||||||
|
} |
||||||
|
|
||||||
|
if ss.Web { |
||||||
|
session, err := app.sessionStore.Get(r, userEmailCookieName) |
||||||
|
if err != nil { |
||||||
|
// The cookie should still save, even if there's an error.
|
||||||
|
// Source: https://github.com/gorilla/sessions/issues/16#issuecomment-143642144
|
||||||
|
log.Error("Getting user email cookie: %v; ignoring", err) |
||||||
|
} |
||||||
|
if confirmed { |
||||||
|
addSessionFlash(app, w, r, "<strong>Subscribed</strong>. You'll now receive future blog posts via email.", nil) |
||||||
|
} else { |
||||||
|
addSessionFlash(app, w, r, "Please check your email and <strong>click the confirmation link</strong> to subscribe.", nil) |
||||||
|
} |
||||||
|
session.Values[userEmailCookieVal] = ss.Email |
||||||
|
err = session.Save(r, w) |
||||||
|
if err != nil { |
||||||
|
log.Error("save email cookie: %s", err) |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
return impart.HTTPError{http.StatusFound, from} |
||||||
|
} |
||||||
|
return impart.WriteSuccess(w, "", http.StatusAccepted) |
||||||
|
} |
||||||
|
|
||||||
|
func handleDeleteEmailSubscription(app *App, w http.ResponseWriter, r *http.Request) error { |
||||||
|
alias := collectionAliasFromReq(r) |
||||||
|
|
||||||
|
vars := mux.Vars(r) |
||||||
|
subID := vars["subscriber"] |
||||||
|
email := r.FormValue("email") |
||||||
|
token := r.FormValue("t") |
||||||
|
slug := r.FormValue("slug") |
||||||
|
isWeb := r.Method == "GET" |
||||||
|
|
||||||
|
// Display collection if this is a collection
|
||||||
|
var c *Collection |
||||||
|
var err error |
||||||
|
if app.cfg.App.SingleUser { |
||||||
|
c, err = app.db.GetCollectionByID(1) |
||||||
|
} else { |
||||||
|
c, err = app.db.GetCollection(alias) |
||||||
|
} |
||||||
|
if err != nil { |
||||||
|
log.Error("Get collection: %s", err) |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
from := c.CanonicalURL() |
||||||
|
|
||||||
|
if subID != "" { |
||||||
|
// User unsubscribing via email, so assume action is taken by either current
|
||||||
|
// user or not current user, and only use the request's information to
|
||||||
|
// satisfy this unsubscribe, i.e. subscriberID and token.
|
||||||
|
err = app.db.DeleteEmailSubscriber(subID, token) |
||||||
|
} else { |
||||||
|
// User unsubscribing through the web app, so assume action is taken by
|
||||||
|
// currently-auth'd user.
|
||||||
|
var userID int64 |
||||||
|
u := getUserSession(app, r) |
||||||
|
if u != nil { |
||||||
|
// User is logged in
|
||||||
|
userID = u.ID |
||||||
|
if userID == c.OwnerID { |
||||||
|
from = "/" + c.Alias + "/" |
||||||
|
} |
||||||
|
} |
||||||
|
if email == "" && userID <= 0 { |
||||||
|
// Get email address from saved cookie
|
||||||
|
session, err := app.sessionStore.Get(r, userEmailCookieName) |
||||||
|
if err != nil { |
||||||
|
log.Error("Unable to get email cookie: %s", err) |
||||||
|
} else { |
||||||
|
email = session.Values[userEmailCookieVal].(string) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if email == "" && userID <= 0 { |
||||||
|
err = fmt.Errorf("No subscriber given.") |
||||||
|
log.Error("Not deleting subscription: %s", err) |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
err = app.db.DeleteEmailSubscriberByUser(email, userID, c.ID) |
||||||
|
} |
||||||
|
if err != nil { |
||||||
|
log.Error("Unable to delete subscriber: %v", err) |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
if isWeb { |
||||||
|
from += slug |
||||||
|
addSessionFlash(app, w, r, "<strong>Unsubscribed</strong>. You will no longer receive these blog posts via email.", nil) |
||||||
|
return impart.HTTPError{http.StatusFound, from} |
||||||
|
} |
||||||
|
return impart.WriteSuccess(w, "", http.StatusAccepted) |
||||||
|
} |
||||||
|
|
||||||
|
func handleConfirmEmailSubscription(app *App, w http.ResponseWriter, r *http.Request) error { |
||||||
|
alias := collectionAliasFromReq(r) |
||||||
|
subID := mux.Vars(r)["subscriber"] |
||||||
|
token := r.FormValue("t") |
||||||
|
|
||||||
|
var c *Collection |
||||||
|
var err error |
||||||
|
if app.cfg.App.SingleUser { |
||||||
|
c, err = app.db.GetCollectionByID(1) |
||||||
|
} else { |
||||||
|
c, err = app.db.GetCollection(alias) |
||||||
|
} |
||||||
|
if err != nil { |
||||||
|
log.Error("Get collection: %s", err) |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
from := c.CanonicalURL() |
||||||
|
|
||||||
|
err = app.db.UpdateSubscriberConfirmed(subID, token) |
||||||
|
if err != nil { |
||||||
|
addSessionFlash(app, w, r, err.Error(), nil) |
||||||
|
return impart.HTTPError{http.StatusFound, from} |
||||||
|
} |
||||||
|
|
||||||
|
addSessionFlash(app, w, r, "<strong>Confirmed</strong>! Thanks. Now you'll receive future blog posts via email.", nil) |
||||||
|
return impart.HTTPError{http.StatusFound, from} |
||||||
|
} |
||||||
|
|
||||||
|
func emailPost(app *App, p *PublicPost, collID int64) error { |
||||||
|
p.augmentContent() |
||||||
|
|
||||||
|
// Do some shortcode replacement.
|
||||||
|
// Since the user is receiving this email, we can assume they're subscribed via email.
|
||||||
|
p.Content = strings.Replace(p.Content, "<!--emailsub-->", `<p id="emailsub">You're subscribed to email updates.</p>`, -1) |
||||||
|
|
||||||
|
if p.HTMLContent == template.HTML("") { |
||||||
|
p.formatContent(app.cfg, false, false) |
||||||
|
} |
||||||
|
p.augmentReadingDestination() |
||||||
|
|
||||||
|
title := p.Title.String |
||||||
|
if title != "" { |
||||||
|
title = p.Title.String + "\n\n" |
||||||
|
} |
||||||
|
plainMsg := title + "A new post from " + p.CanonicalURL(app.cfg.App.Host) + "\n\n" + stripmd.Strip(p.Content) |
||||||
|
plainMsg += ` |
||||||
|
|
||||||
|
--------------------------------------------------------------------------------- |
||||||
|
|
||||||
|
Originally published on ` + p.Collection.DisplayTitle() + ` (` + p.Collection.CanonicalURL() + `), a blog you subscribe to. |
||||||
|
|
||||||
|
Sent to %recipient.to%. Unsubscribe: ` + p.Collection.CanonicalURL() + `email/unsubscribe/%recipient.id%?t=%recipient.token%` |
||||||
|
|
||||||
|
gun := mailgun.NewMailgun(app.cfg.Letters.Domain, app.cfg.Letters.MailgunPrivate) |
||||||
|
m := mailgun.NewMessage(p.Collection.DisplayTitle()+" <"+p.Collection.Alias+"@"+app.cfg.Letters.Domain+">", p.Collection.DisplayTitle()+": "+p.DisplayTitle(), plainMsg) |
||||||
|
replyTo := app.db.GetCollectionAttribute(collID, collAttrLetterReplyTo) |
||||||
|
if replyTo != "" { |
||||||
|
m.SetReplyTo(replyTo) |
||||||
|
} |
||||||
|
|
||||||
|
subs, err := app.db.GetEmailSubscribers(collID, true) |
||||||
|
if err != nil { |
||||||
|
log.Error("Unable to get email subscribers: %v", err) |
||||||
|
return err |
||||||
|
} |
||||||
|
if len(subs) == 0 { |
||||||
|
return nil |
||||||
|
} |
||||||
|
log.Info("[email] Adding %d recipient(s)", len(subs)) |
||||||
|
for _, s := range subs { |
||||||
|
e := s.FinalEmail(app.keys) |
||||||
|
log.Info("[email] Adding %s", e) |
||||||
|
err = m.AddRecipientAndVariables(e, map[string]interface{}{ |
||||||
|
"id": s.ID, |
||||||
|
"to": e, |
||||||
|
"token": s.Token, |
||||||
|
}) |
||||||
|
if err != nil { |
||||||
|
log.Error("Unable to add receipient %s: %s", e, err) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if title != "" { |
||||||
|
title = string(`<h2 id="title">` + p.FormattedDisplayTitle() + `</h2>`) |
||||||
|
} |
||||||
|
m.AddTag("New post") |
||||||
|
|
||||||
|
fontFam := "Lora, Palatino, Baskerville, serif" |
||||||
|
if p.IsSans() { |
||||||
|
fontFam = `"Open Sans", Tahoma, Arial, sans-serif` |
||||||
|
} else if p.IsMonospace() { |
||||||
|
fontFam = `Hack, consolas, Menlo-Regular, Menlo, Monaco, monospace, monospace` |
||||||
|
} |
||||||
|
|
||||||
|
// TODO: move this to a templated file and LESS-generated stylesheet
|
||||||
|
fullHTML := `<html> |
||||||
|
<head> |
||||||
|
<style> |
||||||
|
body { |
||||||
|
font-size: 120%; |
||||||
|
font-family: ` + fontFam + `; |
||||||
|
margin: 1em 2em; |
||||||
|
} |
||||||
|
#article { |
||||||
|
line-height: 1.5; |
||||||
|
margin: 1.5em 0; |
||||||
|
white-space: pre-wrap; |
||||||
|
word-wrap: break-word; |
||||||
|
} |
||||||
|
h1, h2, h3, h4, h5, h6, p, code { |
||||||
|
display: inline |
||||||
|
} |
||||||
|
img, iframe, video { |
||||||
|
max-width: 100% |
||||||
|
} |
||||||
|
#title { |
||||||
|
margin-bottom: 1em; |
||||||
|
display: block; |
||||||
|
} |
||||||
|
.intro { |
||||||
|
font-style: italic; |
||||||
|
font-size: 0.95em; |
||||||
|
} |
||||||
|
div#footer { |
||||||
|
text-align: center; |
||||||
|
max-width: 35em; |
||||||
|
margin: 2em auto; |
||||||
|
} |
||||||
|
div#footer p { |
||||||
|
display: block; |
||||||
|
font-size: 0.86em; |
||||||
|
color: #666; |
||||||
|
} |
||||||
|
hr { |
||||||
|
border: 1px solid #ccc; |
||||||
|
margin: 2em 1em; |
||||||
|
} |
||||||
|
p#emailsub { |
||||||
|
text-align: center; |
||||||
|
display: inline-block !important; |
||||||
|
width: 100%; |
||||||
|
font-style: italic; |
||||||
|
} |
||||||
|
</style> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
<div id="article">` + title + `<p class="intro">From <a href="` + p.CanonicalURL(app.cfg.App.Host) + `">` + p.DisplayCanonicalURL() + `</a></p> |
||||||
|
|
||||||
|
` + string(p.HTMLContent) + `</div> |
||||||
|
<hr /> |
||||||
|
<div id="footer"> |
||||||
|
<p>Originally published on <a href="` + p.Collection.CanonicalURL() + `">` + p.Collection.DisplayTitle() + `</a>, a blog you subscribe to.</p> |
||||||
|
<p>Sent to %recipient.to%. <a href="` + p.Collection.CanonicalURL() + `email/unsubscribe/%recipient.id%?t=%recipient.token%">Unsubscribe</a>.</p> |
||||||
|
</div> |
||||||
|
</body> |
||||||
|
</html>` |
||||||
|
|
||||||
|
// inline CSS
|
||||||
|
html, err := inliner.Inline(fullHTML) |
||||||
|
if err != nil { |
||||||
|
log.Error("Unable to inline email HTML: %v", err) |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
m.SetHtml(html) |
||||||
|
res, _, err := gun.Send(m) |
||||||
|
log.Info("[email] Send result: %s", res) |
||||||
|
if err != nil { |
||||||
|
log.Error("Unable to send post email: %v", err) |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
func sendSubConfirmEmail(app *App, c *Collection, email, subID, token string) error { |
||||||
|
if email == "" { |
||||||
|
return fmt.Errorf("You must supply an email to verify.") |
||||||
|
} |
||||||
|
|
||||||
|
// Send email
|
||||||
|
gun := mailgun.NewMailgun(app.cfg.Letters.Domain, app.cfg.Letters.MailgunPrivate) |
||||||
|
|
||||||
|
plainMsg := "Confirm your subscription to " + c.DisplayTitle() + ` (` + c.CanonicalURL() + `) to start receiving future posts. Simply click the following link (or copy and paste it into your browser): |
||||||
|
|
||||||
|
` + c.CanonicalURL() + "email/confirm/" + subID + "?t=" + token + ` |
||||||
|
|
||||||
|
If you didn't subscribe to this site or you're not sure why you're getting this email, you can delete it. You won't be subscribed or receive any future emails.` |
||||||
|
m := mailgun.NewMessage(c.DisplayTitle()+" <"+c.Alias+"@"+app.cfg.Letters.Domain+">", "Confirm your subscription to "+c.DisplayTitle(), plainMsg, fmt.Sprintf("<%s>", email)) |
||||||
|
m.AddTag("Email Verification") |
||||||
|
|
||||||
|
m.SetHtml(`<html> |
||||||
|
<body style="font-family:Lora, 'Palatino Linotype', Palatino, Baskerville, 'Book Antiqua', 'New York', 'DejaVu serif', serif; font-size: 100%%; margin:1em 2em;"> |
||||||
|
<div style="font-size: 1.2em;"> |
||||||
|
<p>Confirm your subscription to <a href="` + c.CanonicalURL() + `">` + c.DisplayTitle() + `</a> to start receiving future posts:</p> |
||||||
|
<p><a href="` + c.CanonicalURL() + `email/confirm/` + subID + `?t=` + token + `">Subscribe to ` + c.DisplayTitle() + `</a></p> |
||||||
|
<p>If you didn't subscribe to this site or you're not sure why you're getting this email, you can delete it. You won't be subscribed or receive any future emails.</p> |
||||||
|
</div> |
||||||
|
</body> |
||||||
|
</html>`) |
||||||
|
gun.Send(m) |
||||||
|
|
||||||
|
return nil |
||||||
|
} |
@ -0,0 +1,72 @@ |
|||||||
|
package writefreely |
||||||
|
|
||||||
|
import ( |
||||||
|
"github.com/writeas/web-core/log" |
||||||
|
"time" |
||||||
|
) |
||||||
|
|
||||||
|
type PostJob struct { |
||||||
|
ID int64 |
||||||
|
PostID string |
||||||
|
Action string |
||||||
|
Delay int64 |
||||||
|
} |
||||||
|
|
||||||
|
func addJob(app *App, p *PublicPost, action string, delay int64) error { |
||||||
|
j := &PostJob{ |
||||||
|
PostID: p.ID, |
||||||
|
Action: action, |
||||||
|
Delay: delay, |
||||||
|
} |
||||||
|
return app.db.InsertJob(j) |
||||||
|
} |
||||||
|
|
||||||
|
func startPublishJobsQueue(app *App) { |
||||||
|
t := time.NewTicker(62 * time.Second) |
||||||
|
for { |
||||||
|
log.Info("[jobs] Done.") |
||||||
|
<-t.C |
||||||
|
log.Info("[jobs] Fetching email publish jobs...") |
||||||
|
jobs, err := app.db.GetJobsToRun("email") |
||||||
|
if err != nil { |
||||||
|
log.Error("[jobs] %s - Skipping.", err) |
||||||
|
continue |
||||||
|
} |
||||||
|
log.Info("[jobs] Running %d email publish jobs...", len(jobs)) |
||||||
|
err = runJobs(app, jobs, true) |
||||||
|
if err != nil { |
||||||
|
log.Error("[jobs] Failed: %s", err) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func runJobs(app *App, jobs []*PostJob, reqColl bool) error { |
||||||
|
for _, j := range jobs { |
||||||
|
p, err := app.db.GetPost(j.PostID, 0) |
||||||
|
if err != nil { |
||||||
|
log.Info("[job #%d] Unable to get post: %s", j.ID, err) |
||||||
|
continue |
||||||
|
} |
||||||
|
if !p.CollectionID.Valid && reqColl { |
||||||
|
log.Info("[job #%d] Post %s not part of a collection", j.ID, p.ID) |
||||||
|
app.db.DeleteJob(j.ID) |
||||||
|
continue |
||||||
|
} |
||||||
|
coll, err := app.db.GetCollectionByID(p.CollectionID.Int64) |
||||||
|
if err != nil { |
||||||
|
log.Info("[job #%d] Unable to get collection: %s", j.ID, err) |
||||||
|
continue |
||||||
|
} |
||||||
|
coll.hostName = app.cfg.App.Host |
||||||
|
coll.ForPublic() |
||||||
|
p.Collection = &CollectionObj{Collection: *coll} |
||||||
|
err = emailPost(app, p, p.Collection.ID) |
||||||
|
if err != nil { |
||||||
|
log.Error("[job #%d] Failed to email post %s", j.ID, p.ID) |
||||||
|
continue |
||||||
|
} |
||||||
|
log.Info("[job #%d] Success for post %s.", j.ID, p.ID) |
||||||
|
app.db.DeleteJob(j.ID) |
||||||
|
} |
||||||
|
return nil |
||||||
|
} |
@ -0,0 +1,60 @@ |
|||||||
|
/* |
||||||
|
* Copyright © 2021 A Bunch Tell LLC. |
||||||
|
* |
||||||
|
* This file is part of WriteFreely. |
||||||
|
* |
||||||
|
* WriteFreely is free software: you can redistribute it and/or modify |
||||||
|
* it under the terms of the GNU Affero General Public License, included |
||||||
|
* in the LICENSE file in this source code package. |
||||||
|
*/ |
||||||
|
|
||||||
|
package migrations |
||||||
|
|
||||||
|
func supportLetters(db *datastore) error { |
||||||
|
t, err := db.Begin() |
||||||
|
if err != nil { |
||||||
|
t.Rollback() |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
_, err = t.Exec(`CREATE TABLE publishjobs ( |
||||||
|
id ` + db.typeInt() + ` auto_increment, |
||||||
|
post_id ` + db.typeVarChar(16) + ` not null, |
||||||
|
action ` + db.typeVarChar(16) + ` not null, |
||||||
|
delay ` + db.typeTinyInt() + ` not null, |
||||||
|
PRIMARY KEY (id) |
||||||
|
)`) |
||||||
|
if err != nil { |
||||||
|
t.Rollback() |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
// TODO: fix for SQLite database
|
||||||
|
_, err = t.Exec(`CREATE TABLE emailsubscribers ( |
||||||
|
id char(8) not null, |
||||||
|
collection_id int not null, |
||||||
|
user_id int null, |
||||||
|
email varchar(255) null, |
||||||
|
subscribed datetime not null, |
||||||
|
token char(16) not null, |
||||||
|
confirmed tinyint(1) default 0 not null, |
||||||
|
allow_export tinyint(1) default 0 not null, |
||||||
|
constraint eu_coll_email |
||||||
|
unique (collection_id, email), |
||||||
|
constraint eu_coll_user |
||||||
|
unique (collection_id, user_id), |
||||||
|
PRIMARY KEY (id) |
||||||
|
)`) |
||||||
|
if err != nil { |
||||||
|
t.Rollback() |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
err = t.Commit() |
||||||
|
if err != nil { |
||||||
|
t.Rollback() |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
return nil |
||||||
|
} |
@ -0,0 +1,43 @@ |
|||||||
|
/* |
||||||
|
* Copyright © 2020-2021 A Bunch Tell LLC. |
||||||
|
* |
||||||
|
* This file is part of WriteFreely. |
||||||
|
* |
||||||
|
* WriteFreely is free software: you can redistribute it and/or modify |
||||||
|
* it under the terms of the GNU Affero General Public License, included |
||||||
|
* in the LICENSE file in this source code package. |
||||||
|
*/ |
||||||
|
|
||||||
|
package spam |
||||||
|
|
||||||
|
import ( |
||||||
|
"github.com/writeas/web-core/id" |
||||||
|
"strings" |
||||||
|
) |
||||||
|
|
||||||
|
var honeypotField string |
||||||
|
|
||||||
|
func HoneypotFieldName() string { |
||||||
|
if honeypotField == "" { |
||||||
|
honeypotField = id.Generate62RandomString(39) |
||||||
|
} |
||||||
|
return honeypotField |
||||||
|
} |
||||||
|
|
||||||
|
// CleanEmail takes an email address and strips it down to a unique address that can be blocked.
|
||||||
|
func CleanEmail(email string) string { |
||||||
|
emailParts := strings.Split(strings.ToLower(email), "@") |
||||||
|
if len(emailParts) < 2 { |
||||||
|
return "" |
||||||
|
} |
||||||
|
u := emailParts[0] |
||||||
|
d := emailParts[1] |
||||||
|
// Ignore anything after '+'
|
||||||
|
plusIdx := strings.IndexRune(u, '+') |
||||||
|
if plusIdx > -1 { |
||||||
|
u = u[:plusIdx] |
||||||
|
} |
||||||
|
// Strip dots in email address
|
||||||
|
u = strings.ReplaceAll(u, ".", "") |
||||||
|
return u + "@" + d |
||||||
|
} |
Loading…
Reference in new issue