diff --git a/account.go b/account.go
index 2d8d548..f6fca31 100644
--- a/account.go
+++ b/account.go
@@ -875,12 +875,19 @@ func viewEditCollection(app *App, u *User, w http.ResponseWriter, r *http.Reques
*UserPage
*Collection
Silenced bool
+
+ config.EmailCfg
+ LetterReplyTo string
}{
UserPage: NewUserPage(app, r, u, "Edit "+c.DisplayTitle(), flashes),
Collection: c,
Silenced: silenced,
+ EmailCfg: app.cfg.Email,
}
obj.UserPage.CollAlias = c.Alias
+ if obj.EmailCfg.Enabled() {
+ obj.LetterReplyTo = app.db.GetCollectionAttribute(c.ID, collAttrLetterReplyTo)
+ }
showUserPage(w, "collection", obj)
return nil
diff --git a/app.go b/app.go
index eb07073..42d9891 100644
--- a/app.go
+++ b/app.go
@@ -428,6 +428,17 @@ func Initialize(apper Apper, debug bool) (*App, error) {
initActivityPub(apper.App())
+ if apper.App().cfg.Email.Domain != "" || apper.App().cfg.Email.MailgunPrivate != "" {
+ if apper.App().cfg.Email.Domain == "" {
+ log.Error("[FAILED] Starting publish jobs queue: no [letters]domain config value set.")
+ } else if apper.App().cfg.Email.MailgunPrivate == "" {
+ log.Error("[FAILED] Starting publish jobs queue: no [letters]mailgun_private config value set.")
+ } else {
+ log.Info("Starting publish jobs queue...")
+ go startPublishJobsQueue(apper.App())
+ }
+ }
+
// Handle local timeline, if enabled
if apper.App().cfg.App.LocalTimeline {
log.Info("Initializing local timeline...")
diff --git a/collections.go b/collections.go
index e3674fa..b95c20d 100644
--- a/collections.go
+++ b/collections.go
@@ -35,9 +35,12 @@ import (
"github.com/writefreely/writefreely/author"
"github.com/writefreely/writefreely/config"
"github.com/writefreely/writefreely/page"
+ "github.com/writefreely/writefreely/spam"
"golang.org/x/net/idna"
)
+const collAttrLetterReplyTo = "letter_reply_to"
+
type (
// TODO: add Direction to db
// TODO: add Language to db
@@ -91,6 +94,7 @@ type (
Privacy int `schema:"privacy" json:"privacy"`
Pass string `schema:"password" json:"password"`
MathJax bool `schema:"mathjax" json:"mathjax"`
+ EmailSubs bool `schema:"email_subs" json:"email_subs"`
Handle string `schema:"handle" json:"handle"`
// Actual collection values updated in the DB
@@ -102,6 +106,7 @@ type (
Signature *sql.NullString `schema:"signature" json:"signature"`
Monetization *string `schema:"monetization_pointer" json:"monetization_pointer"`
Verification *string `schema:"verification_link" json:"verification_link"`
+ LetterReply *string `schema:"letter_reply" json:"letter_reply"`
Visibility *int `schema:"visibility" json:"public"`
Format *sql.NullString `schema:"format" json:"format"`
}
@@ -361,6 +366,10 @@ func (c *Collection) RenderMathJax() bool {
return c.db.CollectionHasAttribute(c.ID, "render_mathjax")
}
+func (c *Collection) EmailSubsEnabled() bool {
+ return c.db.CollectionHasAttribute(c.ID, "email_subs")
+}
+
func (c *Collection) MonetizationURL() string {
if c.Monetization == "" {
return ""
@@ -612,13 +621,17 @@ type CollectionPage struct {
IsWelcome bool
IsOwner bool
IsCollLoggedIn bool
+ Honeypot string
+ IsSubscriber bool
CanPin bool
Username string
Monetization string
+ Flash template.HTML
Collections *[]Collection
PinnedPosts *[]PublicPost
- IsAdmin bool
- CanInvite bool
+
+ IsAdmin bool
+ CanInvite bool
// Helper field for Chorus mode
CollAlias string
@@ -882,14 +895,20 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro
StaticPage: pageForReq(app, r),
IsCustomDomain: cr.isCustomDomain,
IsWelcome: r.FormValue("greeting") != "",
+ Honeypot: spam.HoneypotFieldName(),
CollAlias: c.Alias,
}
+ flashes, _ := getSessionFlashes(app, w, r, nil)
+ for _, f := range flashes {
+ displayPage.Flash = template.HTML(f)
+ }
displayPage.IsAdmin = u != nil && u.IsAdmin()
displayPage.CanInvite = canUserInvite(app.cfg, displayPage.IsAdmin)
var owner *User
if u != nil {
displayPage.Username = u.Username
displayPage.IsOwner = u.ID == coll.OwnerID
+ displayPage.IsSubscriber = u.IsEmailSubscriber(app, coll.ID)
if displayPage.IsOwner {
// Add in needed information for users viewing their own collection
owner = u
diff --git a/config/config.go b/config/config.go
index 2065ddf..1afd5f3 100644
--- a/config/config.go
+++ b/config/config.go
@@ -170,11 +170,17 @@ type (
DisablePasswordAuth bool `ini:"disable_password_auth"`
}
+ EmailCfg struct {
+ Domain string `ini:"domain"`
+ MailgunPrivate string `ini:"mailgun_private"`
+ }
+
// Config holds the complete configuration for running a writefreely instance
Config struct {
Server ServerCfg `ini:"server"`
Database DatabaseCfg `ini:"database"`
App AppCfg `ini:"app"`
+ Email EmailCfg `ini:"email"`
SlackOauth SlackOauthCfg `ini:"oauth.slack"`
WriteAsOauth WriteAsOauthCfg `ini:"oauth.writeas"`
GitlabOauth GitlabOauthCfg `ini:"oauth.gitlab"`
@@ -235,6 +241,10 @@ func (ac *AppCfg) LandingPath() string {
return ac.Landing
}
+func (lc EmailCfg) Enabled() bool {
+ return lc.Domain != "" && lc.MailgunPrivate != ""
+}
+
func (ac AppCfg) SignupPath() string {
if !ac.OpenRegistration {
return ""
diff --git a/database.go b/database.go
index f8d5a2d..f1e9cfa 100644
--- a/database.go
+++ b/database.go
@@ -14,6 +14,7 @@ import (
"context"
"database/sql"
"fmt"
+ "github.com/go-sql-driver/mysql"
"github.com/writeas/web-core/silobridge"
wf_db "github.com/writefreely/writefreely/db"
"net/http"
@@ -973,6 +974,40 @@ func (db *datastore) UpdateCollection(app *App, c *SubmittedCollection, alias st
}
}
+ // Update EmailSub value
+ if c.EmailSubs {
+ err = db.SetCollectionAttribute(collID, "email_subs", "1")
+ if err != nil {
+ log.Error("Unable to insert email_subs value: %v", err)
+ return err
+ }
+ skipUpdate := false
+ if c.LetterReply != nil {
+ // Strip away any excess spaces
+ trimmed := strings.TrimSpace(*c.LetterReply)
+ // Only update value when it contains "@"
+ if strings.IndexRune(trimmed, '@') > 0 {
+ c.LetterReply = &trimmed
+ } else {
+ // Value appears invalid, so don't update
+ skipUpdate = true
+ }
+ if !skipUpdate {
+ err = db.SetCollectionAttribute(collID, collAttrLetterReplyTo, *c.LetterReply)
+ if err != nil {
+ log.Error("Unable to insert %s value: %v", collAttrLetterReplyTo, err)
+ return err
+ }
+ }
+ }
+ } else {
+ _, err = db.Exec("DELETE FROM collectionattributes WHERE collection_id = ? AND attribute = ?", collID, "email_subs")
+ if err != nil {
+ log.Error("Unable to delete email_subs value: %v", err)
+ return err
+ }
+ }
+
// Update rest of the collection data
if q.Updates != "" {
res, err = db.Exec("UPDATE collections SET "+q.Updates+" WHERE "+q.Conditions, q.Params...)
@@ -2968,3 +3003,247 @@ func (db *datastore) GetProfilePageFromHandle(app *App, handle string) (string,
}
return actorIRI, nil
}
+
+func (db *datastore) AddEmailSubscription(collID, userID int64, email string, confirmed bool) (*EmailSubscriber, error) {
+ friendlyChars := "0123456789BCDFGHJKLMNPQRSTVWXYZbcdfghjklmnpqrstvwxyz"
+ subID := id.GenerateRandomString(friendlyChars, 8)
+ token := id.GenerateRandomString(friendlyChars, 16)
+ emailVal := sql.NullString{
+ String: email,
+ Valid: email != "",
+ }
+ userIDVal := sql.NullInt64{
+ Int64: userID,
+ Valid: userID > 0,
+ }
+
+ _, err := db.Exec("INSERT INTO emailsubscribers (id, collection_id, user_id, email, subscribed, token, confirmed) VALUES (?, ?, ?, ?, "+db.now()+", ?, ?)", subID, collID, userIDVal, emailVal, token, confirmed)
+ if err != nil {
+ if mysqlErr, ok := err.(*mysql.MySQLError); ok {
+ if mysqlErr.Number == mySQLErrDuplicateKey {
+ // Duplicate, so just return existing subscriber information
+ log.Info("Duplicate subscriber for email %s, user %d; returning existing subscriber", email, userID)
+ return db.FetchEmailSubscriber(email, userID, collID)
+ }
+ }
+ return nil, err
+ }
+
+ return &EmailSubscriber{
+ ID: subID,
+ CollID: collID,
+ UserID: userIDVal,
+ Email: emailVal,
+ Token: token,
+ }, nil
+}
+
+func (db *datastore) IsEmailSubscriber(email string, userID, collID int64) bool {
+ var dummy int
+ var err error
+ if email != "" {
+ err = db.QueryRow("SELECT 1 FROM emailsubscribers WHERE email = ? AND collection_id = ?", email, collID).Scan(&dummy)
+ } else {
+ err = db.QueryRow("SELECT 1 FROM emailsubscribers WHERE user_id = ? AND collection_id = ?", userID, collID).Scan(&dummy)
+ }
+ switch {
+ case err == sql.ErrNoRows:
+ return false
+ case err != nil:
+ return false
+ }
+ return true
+}
+
+func (db *datastore) GetEmailSubscribers(collID int64, reqConfirmed bool) ([]*EmailSubscriber, error) {
+ cond := ""
+ if reqConfirmed {
+ cond = " AND confirmed = 1"
+ }
+ rows, err := db.Query(`SELECT s.id, collection_id, user_id, s.email, u.email, subscribed, token, confirmed, allow_export
+FROM emailsubscribers s
+LEFT JOIN users u
+ ON u.id = user_id
+WHERE collection_id = ?`+cond+`
+ORDER BY subscribed DESC`, collID)
+ if err != nil {
+ log.Error("Failed selecting email subscribers for collection %d: %v", collID, err)
+ return nil, err
+ }
+ defer rows.Close()
+
+ var subs []*EmailSubscriber
+ for rows.Next() {
+ s := &EmailSubscriber{}
+ err = rows.Scan(&s.ID, &s.CollID, &s.UserID, &s.Email, &s.acctEmail, &s.Subscribed, &s.Token, &s.Confirmed, &s.AllowExport)
+ if err != nil {
+ log.Error("Failed scanning row from email subscribers: %v", err)
+ continue
+ }
+ subs = append(subs, s)
+ }
+ return subs, nil
+}
+
+func (db *datastore) FetchEmailSubscriberEmail(subID, token string) (string, error) {
+ var email sql.NullString
+ // TODO: return user email if there's a user_id ?
+ err := db.QueryRow("SELECT email FROM emailsubscribers WHERE id = ? AND token = ?", subID, token).Scan(&email)
+ switch {
+ case err == sql.ErrNoRows:
+ return "", fmt.Errorf("Subscriber doesn't exist or token is invalid.")
+ case err != nil:
+ log.Error("Couldn't SELECT email from emailsubscribers: %v", err)
+ return "", fmt.Errorf("Something went very wrong.")
+ }
+
+ return email.String, nil
+}
+
+func (db *datastore) FetchEmailSubscriber(email string, userID, collID int64) (*EmailSubscriber, error) {
+ const emailSubCols = "id, collection_id, user_id, email, subscribed, token, confirmed, allow_export"
+
+ s := &EmailSubscriber{}
+ var row *sql.Row
+ if email != "" {
+ row = db.QueryRow("SELECT "+emailSubCols+" FROM emailsubscribers WHERE email = ? AND collection_id = ?", email, collID)
+ } else {
+ row = db.QueryRow("SELECT "+emailSubCols+" FROM emailsubscribers WHERE user_id = ? AND collection_id = ?", userID, collID)
+ }
+ err := row.Scan(&s.ID, &s.CollID, &s.UserID, &s.Email, &s.Subscribed, &s.Token, &s.Confirmed, &s.AllowExport)
+ switch {
+ case err == sql.ErrNoRows:
+ return nil, nil
+ case err != nil:
+ return nil, err
+ }
+ return s, nil
+}
+
+func (db *datastore) DeleteEmailSubscriber(subID, token string) error {
+ res, err := db.Exec("DELETE FROM emailsubscribers WHERE id = ? AND token = ?", subID, token)
+ if err != nil {
+ return err
+ }
+
+ rowsAffected, _ := res.RowsAffected()
+ if rowsAffected == 0 {
+ return impart.HTTPError{http.StatusNotFound, "Invalid token, or subscriber doesn't exist"}
+ }
+ return nil
+}
+
+func (db *datastore) DeleteEmailSubscriberByUser(email string, userID, collID int64) error {
+ var res sql.Result
+ var err error
+ if email != "" {
+ res, err = db.Exec("DELETE FROM emailsubscribers WHERE email = ? AND collection_id = ?", email, collID)
+ } else {
+ res, err = db.Exec("DELETE FROM emailsubscribers WHERE user_id = ? AND collection_id = ?", userID, collID)
+ }
+ if err != nil {
+ return err
+ }
+
+ rowsAffected, _ := res.RowsAffected()
+ if rowsAffected == 0 {
+ return impart.HTTPError{http.StatusNotFound, "Subscriber doesn't exist"}
+ }
+ return nil
+}
+
+func (db *datastore) UpdateSubscriberConfirmed(subID, token string) error {
+ email, err := db.FetchEmailSubscriberEmail(subID, token)
+ if err != nil {
+ log.Error("Didn't fetch email subscriber: %v", err)
+ return err
+ }
+
+ // TODO: ensure all addresses with original name are also confirmed, e.g. matt+fake@write.as and matt@write.as are now confirmed
+ _, err = db.Exec("UPDATE emailsubscribers SET confirmed = 1 WHERE email = ?", email)
+ if err != nil {
+ log.Error("Could not update email subscriber confirmation status: %v", err)
+ return err
+ }
+ return nil
+}
+
+func (db *datastore) IsSubscriberConfirmed(email string) bool {
+ var dummy int64
+ err := db.QueryRow("SELECT 1 FROM emailsubscribers WHERE email = ? AND confirmed = 1", email).Scan(&dummy)
+ switch {
+ case err == sql.ErrNoRows:
+ return false
+ case err != nil:
+ log.Error("Couldn't SELECT in isSubscriberConfirmed: %v", err)
+ return false
+ }
+
+ return true
+}
+
+func (db *datastore) InsertJob(j *PostJob) error {
+ res, err := db.Exec("INSERT INTO publishjobs (post_id, action, delay) VALUES (?, ?, ?)", j.PostID, j.Action, j.Delay)
+ if err != nil {
+ return err
+ }
+ jobID, err := res.LastInsertId()
+ if err != nil {
+ log.Error("[jobs] Couldn't get last insert ID! %s", err)
+ }
+ log.Info("[jobs] Queued %s job #%d for post %s, delayed %d minutes", j.Action, jobID, j.PostID, j.Delay)
+ return nil
+}
+
+func (db *datastore) UpdateJobForPost(postID string, delay int64) error {
+ _, err := db.Exec("UPDATE publishjobs SET delay = ? WHERE post_id = ?", delay, postID)
+ if err != nil {
+ return fmt.Errorf("Unable to update publish job: %s", err)
+ }
+ log.Info("Updated job for post %s: delay %d", postID, delay)
+ return nil
+}
+
+func (db *datastore) DeleteJob(id int64) error {
+ _, err := db.Exec("DELETE FROM publishjobs WHERE id = ?", id)
+ if err != nil {
+ return err
+ }
+ log.Info("[job #%d] Deleted.", id)
+ return nil
+}
+
+func (db *datastore) DeleteJobByPost(postID string) error {
+ _, err := db.Exec("DELETE FROM publishjobs WHERE post_id = ?", postID)
+ if err != nil {
+ return err
+ }
+ log.Info("[job] Deleted job for post %s", postID)
+ return nil
+}
+
+func (db *datastore) GetJobsToRun(action string) ([]*PostJob, error) {
+ timeWhere := "created < DATE_SUB(NOW(), INTERVAL delay MINUTE) AND created > DATE_SUB(NOW(), INTERVAL delay + 5 MINUTE)"
+ if db.driverName == driverSQLite {
+ timeWhere = "created < DATETIME('now', '-' || delay || ' MINUTE') AND created > DATETIME('now', '-' || (delay+5) || ' MINUTE')"
+ }
+ rows, err := db.Query(`SELECT pj.id, post_id, action, delay
+ FROM publishjobs pj
+ INNER JOIN posts p
+ ON post_id = p.id
+ WHERE action = ? AND `+timeWhere+`
+ ORDER BY created ASC`, action)
+ if err != nil {
+ log.Error("Failed selecting from publishjobs: %v", err)
+ return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve publish jobs."}
+ }
+ defer rows.Close()
+
+ jobs := []*PostJob{}
+ for rows.Next() {
+ j := &PostJob{}
+ err = rows.Scan(&j.ID, &j.PostID, &j.Action, &j.Delay)
+ jobs = append(jobs, j)
+ }
+ return jobs, nil
+}
diff --git a/email.go b/email.go
new file mode 100644
index 0000000..da4590e
--- /dev/null
+++ b/email.go
@@ -0,0 +1,462 @@
+/*
+ * Copyright © 2019-2021 Musing Studio 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}
+ }
+
+ 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 {
+ err = sendSubConfirmEmail(app, c, ss.Email, es.ID, es.Token)
+ if err != nil {
+ log.Error("Failed to send subscription confirmation email: %s", err)
+ return err
+ }
+ }
+
+ 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, "Subscribed . You'll now receive future blog posts via email.", nil)
+ } else {
+ addSessionFlash(app, w, r, "Please check your email and click the confirmation link 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, "Unsubscribed . 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, "Confirmed ! 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, "", `
You're subscribed to email updates.
`, -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.Email.Domain, app.cfg.Email.MailgunPrivate)
+ m := mailgun.NewMessage(p.Collection.DisplayTitle()+" <"+p.Collection.Alias+"@"+app.cfg.Email.Domain+">", stripmd.Strip(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
+ }
+
+ if title != "" {
+ title = string(`` + p.FormattedDisplayTitle() + ` `)
+ }
+ 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 := `
+
+
+
+
+ ` + title + `
From ` + p.DisplayCanonicalURL() + `
+
+` + string(p.HTMLContent) + `
+
+
+
+`
+
+ // inline CSS
+ html, err := inliner.Inline(fullHTML)
+ if err != nil {
+ log.Error("Unable to inline email HTML: %v", err)
+ return err
+ }
+
+ m.SetHtml(html)
+
+ 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)
+ }
+ }
+
+ 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.Email.Domain, app.cfg.Email.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.Email.Domain+">", "Confirm your subscription to "+c.DisplayTitle(), plainMsg, fmt.Sprintf("<%s>", email))
+ m.AddTag("Email Verification")
+
+ m.SetHtml(`
+
+
+
+`)
+ gun.Send(m)
+
+ return nil
+}
diff --git a/go.mod b/go.mod
index 136c9a6..1006487 100644
--- a/go.mod
+++ b/go.mod
@@ -1,10 +1,19 @@
module github.com/writefreely/writefreely
require (
+ github.com/PuerkitoBio/goquery v1.7.0 // indirect
+ github.com/aymerick/douceur v0.2.0
+ github.com/clbanning/mxj v1.8.4 // indirect
github.com/dustin/go-humanize v1.0.1
+ github.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c // indirect
+ github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 // indirect
+ github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4 // indirect
github.com/fatih/color v1.15.0
github.com/go-ini/ini v1.67.0
github.com/go-sql-driver/mysql v1.7.1
+ github.com/go-test/deep v1.0.1 // indirect
+ github.com/gobuffalo/envy v1.9.0 // indirect
+ github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e // indirect
github.com/gorilla/csrf v1.7.1
github.com/gorilla/feeds v1.1.1
github.com/gorilla/mux v1.8.0
@@ -14,11 +23,17 @@ require (
github.com/hashicorp/go-multierror v1.1.1
github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2
github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec
+ github.com/mailgun/mailgun-go v2.0.0+incompatible
github.com/manifoldco/promptui v0.9.0
github.com/mattn/go-sqlite3 v1.14.17
github.com/microcosm-cc/bluemonday v1.0.25
github.com/mitchellh/go-wordwrap v1.0.1
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d
+ github.com/onsi/ginkgo v1.16.4 // indirect
+ github.com/onsi/gomega v1.13.0 // indirect
+ github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be // indirect
+ github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 // indirect
+ github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c // indirect
github.com/stretchr/testify v1.8.4
github.com/urfave/cli/v2 v2.25.7
github.com/writeas/activity v0.1.2
@@ -40,35 +55,31 @@ require (
require (
code.as/core/socks v1.0.0 // indirect
- github.com/aymerick/douceur v0.2.0 // indirect
+ github.com/andybalholm/cascadia v1.1.0 // indirect
github.com/beevik/etree v1.1.0 // indirect
github.com/captncraig/cors v0.0.0-20190703115713-e80254a89df1 // indirect
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect
- github.com/clbanning/mxj v1.8.4 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5 // indirect
github.com/fatih/structs v1.1.0 // indirect
github.com/go-fed/httpsig v0.1.1-0.20200204213531-0ef28562fabe // indirect
- github.com/go-test/deep v1.0.1 // indirect
github.com/gofrs/uuid v3.3.0+incompatible // indirect
github.com/gologme/log v1.2.0 // indirect
- github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e // indirect
github.com/gorilla/css v1.0.0 // indirect
github.com/gorilla/securecookie v1.1.1 // indirect
github.com/hashicorp/errwrap v1.0.0 // indirect
+ github.com/joho/godotenv v1.3.0 // indirect
github.com/jtolds/gls v4.2.1+incompatible // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
- github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be // indirect
+ github.com/rogpeppe/go-internal v1.3.2 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/sasha-s/go-deadlock v0.3.1 // indirect
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
- github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 // indirect
- github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c // indirect
github.com/writeas/go-writeas/v2 v2.0.2 // indirect
github.com/writeas/openssl-go v1.0.0 // indirect
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
diff --git a/go.sum b/go.sum
index f2ab3a8..0666a70 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,9 @@
code.as/core/socks v1.0.0 h1:SPQXNp4SbEwjOAP9VzUahLHak8SDqy5n+9cm9tpjZOs=
code.as/core/socks v1.0.0/go.mod h1:BAXBy5O9s2gmw6UxLqNJcVbWY7C/UPs+801CcSsfWOY=
+github.com/PuerkitoBio/goquery v1.7.0 h1:O5SP3b9JWqMSVMG69zMfj577zwkSNpxrFf7ybS74eiw=
+github.com/PuerkitoBio/goquery v1.7.0/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
+github.com/andybalholm/cascadia v1.1.0 h1:BuuO6sSfQNFRu1LppgbD25Hr2vLYW25JvxHs5zzsLTo=
+github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs=
@@ -24,10 +28,19 @@ github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5 h1:RAV05c0xOkJ3dZGS0
github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c h1:8ISkoahWXwZR41ois5lSJBSVw4D0OV19Ht/JSTzvSv0=
+github.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c/go.mod h1:Yg+htXGokKKdzcwhuNDwVvN+uBxDGXJ7G/VN1d8fa64=
+github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 h1:JWuenKqqX8nojtoVVWjGfOF9635RETekkoH6Cc9SX0A=
+github.com/facebookgo/stack v0.0.0-20160209184415-751773369052/go.mod h1:UbMTZqLaRiH3MsBH8va0n7s1pQYcu3uTb8G4tygF4Zg=
+github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4 h1:7HZCaLC5+BZpmbhCOZJ293Lz68O7PYrF2EzeiFMwCLk=
+github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4/go.mod h1:5tD+neXqOorC30/tWg0LCSkrqj/AR6gu8yY8/fpw1q0=
github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
+github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
+github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
+github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/go-fed/httpsig v0.1.0/go.mod h1:T56HUNYZUQ1AGUzhAYPugZfp36sKApVnGBgKlIY+aIE=
github.com/go-fed/httpsig v0.1.1-0.20200204213531-0ef28562fabe h1:U71giCx5NjRn4Lb71UuprPHqhjxGv3Jqonb9fgcaJH8=
github.com/go-fed/httpsig v0.1.1-0.20200204213531-0ef28562fabe/go.mod h1:T56HUNYZUQ1AGUzhAYPugZfp36sKApVnGBgKlIY+aIE=
@@ -35,12 +48,28 @@ github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
+github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/go-test/deep v1.0.1 h1:UQhStjbkDClarlmv0am7OXXO4/GaPdCGiUiMTvi28sg=
github.com/go-test/deep v1.0.1/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
+github.com/gobuffalo/envy v1.9.0 h1:eZR0DuEgVLfeIb1zIKt3bT4YovIMf9O9LXQeCZLXpqE=
+github.com/gobuffalo/envy v1.9.0/go.mod h1:FurDp9+EDPE4aIUS3ZLyD+7/9fpx7YRt/ukY6jIHf0w=
github.com/gofrs/uuid v3.3.0+incompatible h1:8K4tyRfvU1CYPgJsveYFQMhpFd/wXNM7iK6rR7UHz84=
github.com/gofrs/uuid v3.3.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
+github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
+github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
+github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
+github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
+github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
+github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
+github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
+github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/gologme/log v1.2.0 h1:Ya5Ip/KD6FX7uH0S31QO87nCCSucKtF44TLbTtO7V4c=
github.com/gologme/log v1.2.0/go.mod h1:gq31gQ8wEHkR+WekdWsqDuf8pXTUZA9BnnzTuPz1Y9U=
+github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e h1:JKmoR8x90Iww1ks85zJ1lfDGgIiMDuIptTOhJq+zKyg=
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/csrf v1.7.1 h1:Ir3o2c1/Uzj6FBxMlAUB6SivgVMy1ONXwYgXn+/aHPE=
@@ -65,10 +94,14 @@ github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brv
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
+github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2 h1:wIdDEle9HEy7vBPjC6oKz6ejs3Ut+jmsYvuOoAW2pSM=
github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2/go.mod h1:WtaVKD9TeruTED9ydiaOJU08qGoEPP/LyzTKiD3jEsw=
+github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
+github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
github.com/jtolds/gls v4.2.1+incompatible h1:fSuqC+Gmlu6l/ZYAoZzx2pyucC8Xza35fpRVWLVmUEE=
github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
@@ -76,6 +109,8 @@ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec h1:ZXWuspqypleMuJy4bzYEqlMhJnGAYpLrWe5p7W3CdvI=
github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec/go.mod h1:voECJzdraJmolzPBgL9Z7ANwXf4oMXaTCsIkdiPpR/g=
+github.com/mailgun/mailgun-go v2.0.0+incompatible h1:0FoRHWwMUctnd8KIR3vtZbqdfjpIMxOZgcSa51s8F8o=
+github.com/mailgun/mailgun-go v2.0.0+incompatible/go.mod h1:NWTyU+O4aczg/nsGhQnvHL6v2n5Gy6Sv5tNDVvC6FbU=
github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA=
github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
@@ -92,6 +127,18 @@ github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQ
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ=
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U=
+github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
+github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
+github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
+github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
+github.com/onsi/ginkgo v1.16.2/go.mod h1:CObGmKUOKaSC0RjmoAK7tKyn4Azo5P2IWuoMnvwxz1E=
+github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=
+github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
+github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
+github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
+github.com/onsi/gomega v1.13.0 h1:7lLHu94wT9Ij0o6EWWclhu0aOh32VxhkwEJvzuWPeak=
+github.com/onsi/gomega v1.13.0/go.mod h1:lRk9szgn8TxENtWd0Tp4c3wjlRfMTMH27I+3Je41yGY=
github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 h1:q2e307iGHPdTGp0hoxKjt1H5pDo6utceo3dQVK3I5XQ=
github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5/go.mod h1:jvVRKCrJTQWu0XVbaOlby/2lO20uSCHEMzzplHXte1o=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@@ -100,6 +147,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be h1:ta7tUOvsPHVHGom5hKW5VXNc2xZIkfCKP8iaqOyYtUQ=
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q=
+github.com/rogpeppe/go-internal v1.3.2 h1:XU784Pr0wdahMY2bYcyK6N1KuaRAdLtqD4qd8D18Bfs=
+github.com/rogpeppe/go-internal v1.3.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sasha-s/go-deadlock v0.3.1 h1:sqv7fDNShgjcaxkO0JNcOAlr8B9+cV5Ey/OB71efZx0=
@@ -111,6 +160,8 @@ github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304/go.mod h1
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c h1:Ho+uVpkel/udgjbwB5Lktg9BtvJSh2DT0Hi6LPSyI2w=
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
@@ -153,31 +204,53 @@ github.com/writefreely/go-nodeinfo v1.2.0 h1:La+YbTCvmpTwFhBSlebWDDL81N88Qf/SCAv
github.com/writefreely/go-nodeinfo v1.2.0/go.mod h1:UTvE78KpcjYOlRHupZIiSEFcXHioTXuacCbHU+CAcPg=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
+github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20180527072434-ab813273cd59/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
+golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
+golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180525142821-c11f84a56e43/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -194,6 +267,7 @@ golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
@@ -202,17 +276,39 @@ golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
+google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
+google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
+google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
+google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
+google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
+google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
+google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
+gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/ini.v1 v1.55.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.62.0 h1:duBzk771uxoUuOlyRLkHsygud9+5lrlGjdFBb4mSKDU=
gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
+gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
+gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0 h1:POO/ycCATvegFmVuPpQzZFJ+pGZeX22Ufu6fibxDVjU=
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/jobs.go b/jobs.go
new file mode 100644
index 0000000..251b82d
--- /dev/null
+++ b/jobs.go
@@ -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
+}
diff --git a/less/core.less b/less/core.less
index 709ba1e..1b418ba 100644
--- a/less/core.less
+++ b/less/core.less
@@ -210,6 +210,10 @@ body {
pre {
line-height: 1.5;
}
+ .flash {
+ text-align: center;
+ margin-bottom: 4em;
+ }
}
subpage {
#wrapper {
@@ -1597,6 +1601,18 @@ pre.code-block {
overflow-x: auto;
}
+#emailsub {
+ text-align: center;
+}
+p#emailsub {
+ display: inline-block !important;
+ width: 100%;
+ font-style: italic;
+}
+#subscribe-btn {
+ margin-left: 0.5em;
+}
+
#org-nav {
font-family: @sansFont;
font-size: 1.1em;
diff --git a/migrations/drivers.go b/migrations/drivers.go
index 967ee97..800d2a6 100644
--- a/migrations/drivers.go
+++ b/migrations/drivers.go
@@ -36,6 +36,13 @@ func (db *datastore) typeSmallInt() string {
return "SMALLINT"
}
+func (db *datastore) typeTinyInt() string {
+ if db.driverName == driverSQLite {
+ return "INTEGER"
+ }
+ return "TINYINT"
+}
+
func (db *datastore) typeText() string {
return "TEXT"
}
@@ -65,6 +72,15 @@ func (db *datastore) typeDateTime() string {
return "DATETIME"
}
+func (db *datastore) typeIntPrimaryKey() string {
+ if db.driverName == driverSQLite {
+ // From docs: "In SQLite, a column with type INTEGER PRIMARY KEY is an alias for the ROWID (except in WITHOUT
+ // ROWID tables) which is always a 64-bit signed integer."
+ return "INTEGER PRIMARY KEY"
+ }
+ return "INT AUTO_INCREMENT PRIMARY KEY"
+}
+
func (db *datastore) collateMultiByte() string {
if db.driverName == driverSQLite {
return ""
diff --git a/migrations/migrations.go b/migrations/migrations.go
index d2da8f4..d4f9d4b 100644
--- a/migrations/migrations.go
+++ b/migrations/migrations.go
@@ -65,9 +65,10 @@ var migrations = []Migration{
New("support oauth attach", oauthAttach), // V6 -> V7
New("support oauth via invite", oauthInvites), // V7 -> V8 (v0.12.0)
New("optimize drafts retrieval", optimizeDrafts), // V8 -> V9
- New("support post signatures", supportPostSignatures), // V9 -> V10
+ New("support post signatures", supportPostSignatures), // V9 -> V10 (v0.13.0)
New("Widen oauth_users.access_token", widenOauthAcceesToken), // V10 -> V11
- New("support verifying fedi profile", fediverseVerifyProfile), // V11 -> V12
+ New("support verifying fedi profile", fediverseVerifyProfile), // V11 -> V12 (v0.14.0)
+ New("support newsletters", supportLetters), // V12 -> V13
}
// CurrentVer returns the current migration version the application is on
diff --git a/migrations/v13.go b/migrations/v13.go
new file mode 100644
index 0000000..908ceac
--- /dev/null
+++ b/migrations/v13.go
@@ -0,0 +1,58 @@
+/*
+ * Copyright © 2021 Musing Studio 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.typeIntPrimaryKey() + `,
+ post_id ` + db.typeVarChar(16) + ` not null,
+ action ` + db.typeVarChar(16) + ` not null,
+ delay ` + db.typeTinyInt() + ` not null
+)`)
+ if err != nil {
+ t.Rollback()
+ return err
+ }
+
+ _, err = t.Exec(`CREATE TABLE emailsubscribers (
+ id ` + db.typeChar(8) + ` not null,
+ collection_id ` + db.typeInt() + ` not null,
+ user_id ` + db.typeInt() + ` null,
+ email ` + db.typeVarChar(255) + ` null,
+ subscribed ` + db.typeDateTime() + ` not null,
+ token ` + db.typeChar(16) + ` not null,
+ confirmed ` + db.typeBool() + ` default 0 not null,
+ allow_export ` + db.typeBool() + ` 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
+}
diff --git a/posts.go b/posts.go
index c9ac6ec..589000c 100644
--- a/posts.go
+++ b/posts.go
@@ -14,6 +14,7 @@ import (
"database/sql"
"encoding/json"
"fmt"
+ "github.com/writefreely/writefreely/spam"
"html/template"
"net/http"
"net/url"
@@ -652,8 +653,17 @@ func newPost(app *App, w http.ResponseWriter, r *http.Request) error {
// Write success now
response := impart.WriteSuccess(w, newPost, http.StatusCreated)
- if newPost.Collection != nil && !app.cfg.App.Private && app.cfg.App.Federation && !newPost.Created.After(time.Now()) {
- go federatePost(app, newPost, newPost.Collection.ID, false)
+ if newPost.Collection != nil {
+ if !app.cfg.App.Private && app.cfg.App.Federation && !newPost.Created.After(time.Now()) {
+ go federatePost(app, newPost, newPost.Collection.ID, false)
+ }
+ if app.cfg.Email.Enabled() && newPost.Collection.EmailSubsEnabled() {
+ go app.db.InsertJob(&PostJob{
+ PostID: newPost.ID,
+ Action: "email",
+ Delay: emailSendDelay,
+ })
+ }
}
return response
@@ -953,16 +963,23 @@ func addPost(app *App, w http.ResponseWriter, r *http.Request) error {
return err
}
- if !app.cfg.App.Private && app.cfg.App.Federation {
- for _, pRes := range *res {
- if pRes.Code != http.StatusOK {
- continue
- }
+ for _, pRes := range *res {
+ if pRes.Code != http.StatusOK {
+ continue
+ }
+ if !app.cfg.App.Private && app.cfg.App.Federation {
if !pRes.Post.Created.After(time.Now()) {
pRes.Post.Collection.hostName = app.cfg.App.Host
go federatePost(app, pRes.Post, pRes.Post.Collection.ID, false)
}
}
+ if app.cfg.Email.Enabled() && pRes.Post.Collection.EmailSubsEnabled() {
+ go app.db.InsertJob(&PostJob{
+ PostID: pRes.Post.ID,
+ Action: "email",
+ Delay: emailSendDelay,
+ })
+ }
}
return impart.WriteSuccess(w, res, http.StatusOK)
}
@@ -1164,6 +1181,15 @@ func (p *PublicPost) CanonicalURL(hostName string) string {
return p.Collection.CanonicalURL() + p.Slug.String
}
+func (pp *PublicPost) DisplayCanonicalURL() string {
+ us := pp.CanonicalURL(pp.Collection.hostName)
+ u, err := url.Parse(us)
+ if err != nil {
+ return us
+ }
+ return u.Hostname() + u.Path
+}
+
func (p *PublicPost) ActivityObject(app *App) *activitystreams.Object {
cfg := app.cfg
var o *activitystreams.Object
@@ -1532,6 +1558,15 @@ Are you sure it was ever here?`,
} else {
p.extractData()
p.Content = strings.Replace(p.Content, "", "", 1)
+ if app.cfg.Email.Enabled() && c.EmailSubsEnabled() {
+ // TODO: indicate plan is inactive or subs disabled when OWNER is viewing their own post.
+ if u != nil && u.IsEmailSubscriber(app, c.ID) {
+ p.Content = strings.Replace(p.Content, "", `You're subscribed to email updates. Unsubscribe .
`, -1)
+ } else {
+ p.Content = strings.Replace(p.Content, "", ``, -1)
+ }
+ }
+ p.Content = strings.Replace(p.Content, "<!--emailsub-->", "", 1)
// TODO: move this to function
p.formatContent(app.cfg, cr.isCollOwner, true)
tp := CollectionPostPage{
@@ -1596,6 +1631,14 @@ func (p *Post) extractData() {
p.extractImages()
}
+func (p *Post) IsSans() bool {
+ return p.Font == "sans"
+}
+
+func (p *Post) IsMonospace() bool {
+ return p.Font == "mono"
+}
+
func (rp *RawPost) UserFacingCreated() string {
return rp.Created.Format(postMetaDateFormat)
}
diff --git a/routes.go b/routes.go
index c44dbbb..0a44821 100644
--- a/routes.go
+++ b/routes.go
@@ -147,6 +147,9 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
apiColls.HandleFunc("/{alias}/collect", handler.All(addPost)).Methods("POST")
apiColls.HandleFunc("/{alias}/pin", handler.All(pinPost)).Methods("POST")
apiColls.HandleFunc("/{alias}/unpin", handler.All(pinPost)).Methods("POST")
+ apiColls.HandleFunc("/{alias}/email/subscribe", handler.All(handleCreateEmailSubscription)).Methods("POST")
+ apiColls.HandleFunc("/{alias}/email/subscribe", handler.All(handleDeleteEmailSubscription)).Methods("DELETE")
+ apiColls.HandleFunc("/{collection}/email/unsubscribe", handler.All(handleDeleteEmailSubscription)).Methods("GET")
apiColls.HandleFunc("/{alias}/inbox", handler.All(handleFetchCollectionInbox)).Methods("POST")
apiColls.HandleFunc("/{alias}/outbox", handler.AllReader(handleFetchCollectionOutbox)).Methods("GET")
apiColls.HandleFunc("/{alias}/following", handler.AllReader(handleFetchCollectionFollowing)).Methods("GET")
@@ -223,6 +226,8 @@ func RouteCollections(handler *Handler, r *mux.Router) {
r.HandleFunc("/tag:{tag}/feed/", handler.Web(ViewFeed, UserLevelReader))
r.HandleFunc("/sitemap.xml", handler.AllReader(handleViewSitemap))
r.HandleFunc("/feed/", handler.AllReader(ViewFeed))
+ r.HandleFunc("/email/confirm/{subscriber}", handler.All(handleConfirmEmailSubscription)).Methods("GET")
+ r.HandleFunc("/email/unsubscribe/{subscriber}", handler.All(handleDeleteEmailSubscription)).Methods("GET")
r.HandleFunc("/{slug}", handler.CollectionPostOrStatic)
r.HandleFunc("/{slug}/edit", handler.Web(handleViewPad, UserLevelUser))
r.HandleFunc("/{slug}/edit/meta", handler.Web(handleViewMeta, UserLevelUser))
diff --git a/session.go b/session.go
index 91c42fe..03d3963 100644
--- a/session.go
+++ b/session.go
@@ -21,6 +21,10 @@ import (
const (
day = 86400
sessionLength = 180 * day
+
+ userEmailCookieName = "ue"
+ userEmailCookieVal = "email"
+
cookieName = "wfu"
cookieUserVal = "u"
diff --git a/spam/email.go b/spam/email.go
new file mode 100644
index 0000000..de017ab
--- /dev/null
+++ b/spam/email.go
@@ -0,0 +1,43 @@
+/*
+ * Copyright © 2020-2021 Musing Studio 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
+}
diff --git a/templates/collection.tmpl b/templates/collection.tmpl
index 16ef873..8380910 100644
--- a/templates/collection.tmpl
+++ b/templates/collection.tmpl
@@ -103,6 +103,12 @@
{{end}}
+ {{if .Flash}}
+
+ {{end}}
+
{{template "posts" .}}
{{if gt .TotalPages 1}}
@@ -115,6 +121,8 @@
{{end}}
{{end}}
+ {{if not .IsWelcome}}{{template "emailsubscribe" .}}{{end}}
+
{{if .Posts}}{{else}}{{end}}
{{if .ShowFooterBranding }}
diff --git a/templates/include/post-render.tmpl b/templates/include/post-render.tmpl
index fed16d0..4e98d80 100644
--- a/templates/include/post-render.tmpl
+++ b/templates/include/post-render.tmpl
@@ -105,3 +105,28 @@
{{end}}
+
+{{define "emailsubscribe"}}
+ {{if .EmailSubsEnabled}}
+
+ {{if .IsSubscriber}}
+
You're subscribed to email updates. Unsubscribe .
+ {{else}}
+
+
+ {{end}}
+
+ {{end}}
+{{end}}
\ No newline at end of file
diff --git a/templates/user/collection.tmpl b/templates/user/collection.tmpl
index fe32df6..c30b479 100644
--- a/templates/user/collection.tmpl
+++ b/templates/user/collection.tmpl
@@ -90,6 +90,44 @@ textarea.section.norm {
+
+
Updates
+
+
Keep readers updated with your latest posts wherever they are.
+
+
+
+
Display Format
@@ -254,6 +292,13 @@ var $customDomain = document.getElementById('domain-alias');
var $customHandleEnv = document.getElementById('custom-handle-env');
var $normalHandleEnv = document.getElementById('normal-handle-env');
+var $emailSubsCheck = document.getElementById('email_subs');
+var $letterReply = document.getElementById('letter_reply');
+H.getEl('email_subs').on('click', function() {
+ let show = $emailSubsCheck.checked
+ $letterReply.disabled = !show
+})
+
if (matchMedia('(pointer:fine)').matches) {
// Only initialize Ace editor on devices with a mouse
var opt = {
diff --git a/users.go b/users.go
index 05c7f24..449427d 100644
--- a/users.go
+++ b/users.go
@@ -134,3 +134,7 @@ func (u *User) IsAdmin() bool {
func (u *User) IsSilenced() bool {
return u.Status&UserSilenced != 0
}
+
+func (u *User) IsEmailSubscriber(app *App, collID int64) bool {
+ return app.db.IsEmailSubscriber("", u.ID, collID)
+}