summaryrefslogtreecommitdiff
path: root/src/mailinglist
diff options
context:
space:
mode:
Diffstat (limited to 'src/mailinglist')
-rw-r--r--src/mailinglist/mailer.go143
-rw-r--r--src/mailinglist/mailinglist.go273
-rw-r--r--src/mailinglist/store.go245
-rw-r--r--src/mailinglist/store_test.go95
4 files changed, 756 insertions, 0 deletions
diff --git a/src/mailinglist/mailer.go b/src/mailinglist/mailer.go
new file mode 100644
index 0000000..07d6c3a
--- /dev/null
+++ b/src/mailinglist/mailer.go
@@ -0,0 +1,143 @@
+package mailinglist
+
+import (
+ "context"
+ "errors"
+ "strings"
+
+ "github.com/emersion/go-sasl"
+ "github.com/emersion/go-smtp"
+ "github.com/mediocregopher/blog.mediocregopher.com/srv/cfg"
+ "github.com/mediocregopher/mediocre-go-lib/v2/mctx"
+ "github.com/mediocregopher/mediocre-go-lib/v2/mlog"
+)
+
+// Mailer is used to deliver emails to arbitrary recipients.
+type Mailer interface {
+ Send(to, subject, body string) error
+}
+
+type logMailer struct {
+ logger *mlog.Logger
+}
+
+// NewLogMailer returns a Mailer instance which will not actually send any
+// emails, it will only log to the given Logger when Send is called.
+func NewLogMailer(logger *mlog.Logger) Mailer {
+ return &logMailer{logger: logger}
+}
+
+func (l *logMailer) Send(to, subject, body string) error {
+ ctx := mctx.Annotate(context.Background(),
+ "to", to,
+ "subject", subject,
+ )
+ l.logger.Info(ctx, "would have sent email")
+ return nil
+}
+
+// NullMailer acts as a Mailer but actually just does nothing.
+var NullMailer = nullMailer{}
+
+type nullMailer struct{}
+
+func (nullMailer) Send(to, subject, body string) error {
+ return nil
+}
+
+// MailerParams are used to initialize a new Mailer instance.
+type MailerParams struct {
+ SMTPAddr string
+
+ // Optional, if not given then no auth is attempted.
+ SMTPAuth sasl.Client
+
+ // The sending email address to use for all emails being sent.
+ SendAs string
+}
+
+// SetupCfg implement the cfg.Cfger interface.
+func (m *MailerParams) SetupCfg(cfg *cfg.Cfg) {
+
+ cfg.StringVar(&m.SMTPAddr, "ml-smtp-addr", "", "Address of SMTP server to use for sending emails for the mailing list")
+ smtpAuthStr := cfg.String("ml-smtp-auth", "", "user:pass to use when authenticating with the mailing list SMTP server. The given user will also be used as the From address.")
+
+ cfg.OnInit(func(ctx context.Context) error {
+ if m.SMTPAddr == "" {
+ return nil
+ }
+
+ smtpAuthParts := strings.SplitN(*smtpAuthStr, ":", 2)
+ if len(smtpAuthParts) < 2 {
+ return errors.New("invalid -ml-smtp-auth")
+ }
+
+ m.SMTPAuth = sasl.NewPlainClient("", smtpAuthParts[0], smtpAuthParts[1])
+ m.SendAs = smtpAuthParts[0]
+
+ return nil
+ })
+}
+
+// Annotate implements mctx.Annotator interface.
+func (m *MailerParams) Annotate(a mctx.Annotations) {
+ if m.SMTPAddr == "" {
+ return
+ }
+
+ a["smtpAddr"] = m.SMTPAddr
+ a["smtpSendAs"] = m.SendAs
+}
+
+type mailer struct {
+ params MailerParams
+}
+
+// NewMailer initializes and returns a Mailer which will use an external SMTP
+// server to deliver email.
+func NewMailer(params MailerParams) Mailer {
+ return &mailer{
+ params: params,
+ }
+}
+
+func (m *mailer) Send(to, subject, body string) error {
+
+ msg := []byte("From: " + m.params.SendAs + "\r\n" +
+ "To: " + to + "\r\n" +
+ "Subject: " + subject + "\r\n\r\n" +
+ body + "\r\n")
+
+ c, err := smtp.Dial(m.params.SMTPAddr)
+ if err != nil {
+ return err
+ }
+ defer c.Close()
+
+ if err = c.Auth(m.params.SMTPAuth); err != nil {
+ return err
+ }
+
+ if err = c.Mail(m.params.SendAs, nil); err != nil {
+ return err
+ }
+
+ if err = c.Rcpt(to); err != nil {
+ return err
+ }
+
+ w, err := c.Data()
+ if err != nil {
+ return err
+ }
+
+ if _, err = w.Write(msg); err != nil {
+ return err
+ }
+
+ if err = w.Close(); err != nil {
+ return err
+ }
+
+ return c.Quit()
+}
diff --git a/src/mailinglist/mailinglist.go b/src/mailinglist/mailinglist.go
new file mode 100644
index 0000000..d9bdcc0
--- /dev/null
+++ b/src/mailinglist/mailinglist.go
@@ -0,0 +1,273 @@
+// Package mailinglist manages the list of subscribed emails and allows emailing
+// out to them.
+package mailinglist
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "html/template"
+ "io"
+ "net/url"
+ "strings"
+
+ "github.com/google/uuid"
+ "github.com/mediocregopher/blog.mediocregopher.com/srv/cfg"
+ "github.com/mediocregopher/mediocre-go-lib/v2/mctx"
+ "github.com/tilinna/clock"
+)
+
+var (
+ // ErrAlreadyVerified is used when the email is already fully subscribed.
+ ErrAlreadyVerified = errors.New("email is already subscribed")
+)
+
+// MailingList is able to subscribe, unsubscribe, and iterate through emails.
+type MailingList interface {
+
+ // May return ErrAlreadyVerified.
+ BeginSubscription(email string) error
+
+ // May return ErrNotFound or ErrAlreadyVerified.
+ FinalizeSubscription(subToken string) error
+
+ // May return ErrNotFound.
+ Unsubscribe(unsubToken string) error
+
+ // Publish blasts the mailing list with an update about a new blog post.
+ Publish(postTitle, postURL string) error
+}
+
+// Params are parameters used to initialize a new MailingList. All fields are
+// required unless otherwise noted.
+type Params struct {
+ Store Store
+ Mailer Mailer
+ Clock clock.Clock
+
+ // PublicURL is the base URL which site visitors can navigate to.
+ // MailingList will generate links based on this value.
+ PublicURL *url.URL
+}
+
+// SetupCfg implement the cfg.Cfger interface.
+func (p *Params) SetupCfg(cfg *cfg.Cfg) {
+ publicURLStr := cfg.String("ml-public-url", "http://localhost:4000", "URL this service is accessible at")
+
+ cfg.OnInit(func(ctx context.Context) error {
+ var err error
+ *publicURLStr = strings.TrimSuffix(*publicURLStr, "/")
+ if p.PublicURL, err = url.Parse(*publicURLStr); err != nil {
+ return fmt.Errorf("parsing -ml-public-url: %w", err)
+ }
+
+ return nil
+ })
+}
+
+// Annotate implements mctx.Annotator interface.
+func (p *Params) Annotate(a mctx.Annotations) {
+ a["mlPublicURL"] = p.PublicURL
+}
+
+// New initializes and returns a MailingList instance using the given Params.
+func New(params Params) MailingList {
+ return &mailingList{params: params}
+}
+
+type mailingList struct {
+ params Params
+}
+
+var beginSubTpl = template.Must(template.New("beginSub").Parse(`
+Welcome to the Mediocre Blog mailing list! By subscribing to this mailing list
+you are signing up to receive an email everytime a new blog post is published.
+
+In order to complete your subscription please navigate to the following link:
+
+{{ .SubLink }}
+
+This mailing list is built and run using my own hardware and software, and I
+solemnly swear that you'll never receive an email from it unless there's a new
+blog post.
+
+If you did not initiate this email, and/or do not wish to subscribe to the
+mailing list, then simply delete this email and pretend that nothing ever
+happened.
+
+- Brian
+`))
+
+func (m *mailingList) BeginSubscription(email string) error {
+
+ emailRecord, err := m.params.Store.Get(email)
+
+ if errors.Is(err, ErrNotFound) {
+ emailRecord = Email{
+ Email: email,
+ SubToken: uuid.New().String(),
+ CreatedAt: m.params.Clock.Now(),
+ }
+
+ if err := m.params.Store.Set(emailRecord); err != nil {
+ return fmt.Errorf("storing pending email: %w", err)
+ }
+
+ } else if err != nil {
+ return fmt.Errorf("finding existing email record: %w", err)
+
+ } else if !emailRecord.VerifiedAt.IsZero() {
+ return ErrAlreadyVerified
+ }
+
+ body := new(bytes.Buffer)
+ err = beginSubTpl.Execute(body, struct {
+ SubLink string
+ }{
+ SubLink: fmt.Sprintf(
+ "%s/mailinglist/finalize?subToken=%s",
+ m.params.PublicURL.String(),
+ emailRecord.SubToken,
+ ),
+ })
+
+ if err != nil {
+ return fmt.Errorf("executing beginSubTpl: %w", err)
+ }
+
+ err = m.params.Mailer.Send(
+ email,
+ "Mediocre Blog - Please verify your email address",
+ body.String(),
+ )
+
+ if err != nil {
+ return fmt.Errorf("sending email: %w", err)
+ }
+
+ return nil
+}
+
+func (m *mailingList) FinalizeSubscription(subToken string) error {
+ emailRecord, err := m.params.Store.GetBySubToken(subToken)
+
+ if err != nil {
+ return fmt.Errorf("retrieving email record: %w", err)
+
+ } else if !emailRecord.VerifiedAt.IsZero() {
+ return ErrAlreadyVerified
+ }
+
+ emailRecord.VerifiedAt = m.params.Clock.Now()
+ emailRecord.UnsubToken = uuid.New().String()
+
+ if err := m.params.Store.Set(emailRecord); err != nil {
+ return fmt.Errorf("storing verified email: %w", err)
+ }
+
+ return nil
+}
+
+func (m *mailingList) Unsubscribe(unsubToken string) error {
+ emailRecord, err := m.params.Store.GetByUnsubToken(unsubToken)
+
+ if err != nil {
+ return fmt.Errorf("retrieving email record: %w", err)
+ }
+
+ if err := m.params.Store.Delete(emailRecord.Email); err != nil {
+ return fmt.Errorf("deleting email record: %w", err)
+ }
+
+ return nil
+}
+
+var publishTpl = template.Must(template.New("publish").Parse(`
+A new post has been published to the Mediocre Blog!
+
+{{ .PostTitle }}
+{{ .PostURL }}
+
+If you're interested then please check it out!
+
+If you'd like to unsubscribe from this mailing list then visit the following
+link instead:
+
+{{ .UnsubURL }}
+
+- Brian
+`))
+
+type multiErr []error
+
+func (m multiErr) Error() string {
+ if len(m) == 0 {
+ panic("multiErr with no members")
+ }
+
+ b := new(strings.Builder)
+ fmt.Fprintln(b, "The following errors were encountered:")
+ for _, err := range m {
+ fmt.Fprintf(b, "\t- %s\n", err.Error())
+ }
+
+ return b.String()
+}
+
+func (m *mailingList) Publish(postTitle, postURL string) error {
+
+ var mErr multiErr
+
+ iter := m.params.Store.GetAll()
+ for {
+ emailRecord, err := iter()
+ if errors.Is(err, io.EOF) {
+ break
+
+ } else if err != nil {
+ mErr = append(mErr, fmt.Errorf("iterating through email records: %w", err))
+ break
+
+ } else if emailRecord.VerifiedAt.IsZero() {
+ continue
+ }
+
+ body := new(bytes.Buffer)
+ err = publishTpl.Execute(body, struct {
+ PostTitle string
+ PostURL string
+ UnsubURL string
+ }{
+ PostTitle: postTitle,
+ PostURL: postURL,
+ UnsubURL: fmt.Sprintf(
+ "%s/mailinglist/unsubscribe?unsubToken=%s",
+ m.params.PublicURL.String(),
+ emailRecord.UnsubToken,
+ ),
+ })
+
+ if err != nil {
+ mErr = append(mErr, fmt.Errorf("rendering publish email template for %q: %w", emailRecord.Email, err))
+ continue
+ }
+
+ err = m.params.Mailer.Send(
+ emailRecord.Email,
+ fmt.Sprintf("Mediocre Blog - New Post! - %s", postTitle),
+ body.String(),
+ )
+
+ if err != nil {
+ mErr = append(mErr, fmt.Errorf("sending email to %q: %w", emailRecord.Email, err))
+ continue
+ }
+ }
+
+ if len(mErr) > 0 {
+ return mErr
+ }
+
+ return nil
+}
diff --git a/src/mailinglist/store.go b/src/mailinglist/store.go
new file mode 100644
index 0000000..49e7617
--- /dev/null
+++ b/src/mailinglist/store.go
@@ -0,0 +1,245 @@
+package mailinglist
+
+import (
+ "crypto/sha512"
+ "database/sql"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "io"
+ "path"
+ "strings"
+ "time"
+
+ _ "github.com/mattn/go-sqlite3"
+ "github.com/mediocregopher/blog.mediocregopher.com/srv/cfg"
+ migrate "github.com/rubenv/sql-migrate"
+)
+
+var (
+ // ErrNotFound is used to indicate an email could not be found in the
+ // database.
+ ErrNotFound = errors.New("no record found")
+)
+
+// EmailIterator will iterate through a sequence of emails, returning the next
+// email in the sequence on each call, or returning io.EOF.
+type EmailIterator func() (Email, error)
+
+// Email describes all information related to an email which has yet
+// to be verified.
+type Email struct {
+ Email string
+ SubToken string
+ CreatedAt time.Time
+
+ UnsubToken string
+ VerifiedAt time.Time
+}
+
+// Store is used for storing MailingList related information.
+type Store interface {
+
+ // Set is used to set the information related to an email.
+ Set(Email) error
+
+ // Get will return the record for the given email, or ErrNotFound.
+ Get(email string) (Email, error)
+
+ // GetBySubToken will return the record for the given SubToken, or
+ // ErrNotFound.
+ GetBySubToken(subToken string) (Email, error)
+
+ // GetByUnsubToken will return the record for the given UnsubToken, or
+ // ErrNotFound.
+ GetByUnsubToken(unsubToken string) (Email, error)
+
+ // Delete will delete the record for the given email.
+ Delete(email string) error
+
+ // GetAll returns all emails for which there is a record.
+ GetAll() EmailIterator
+
+ Close() error
+}
+
+var migrations = []*migrate.Migration{
+ &migrate.Migration{
+ Id: "1",
+ Up: []string{
+ `CREATE TABLE emails (
+ id TEXT PRIMARY KEY,
+ email TEXT NOT NULL,
+ sub_token TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+
+ unsub_token TEXT,
+ verified_at INTEGER
+ )`,
+ },
+ Down: []string{"DROP TABLE emails"},
+ },
+}
+
+type store struct {
+ db *sql.DB
+}
+
+// NewStore initializes a new Store using a sqlite3 database in the given
+// DataDir.
+func NewStore(dataDir cfg.DataDir) (Store, error) {
+
+ path := path.Join(dataDir.Path, "mailinglist.sqlite3")
+
+ db, err := sql.Open("sqlite3", path)
+ if err != nil {
+ return nil, fmt.Errorf("opening sqlite file at %q: %w", path, err)
+ }
+
+ migrations := &migrate.MemoryMigrationSource{Migrations: migrations}
+
+ if _, err := migrate.Exec(db, "sqlite3", migrations, migrate.Up); err != nil {
+ return nil, fmt.Errorf("running migrations: %w", err)
+ }
+
+ return &store{
+ db: db,
+ }, nil
+}
+
+func (s *store) emailID(email string) string {
+ email = strings.ToLower(email)
+ h := sha512.New()
+ h.Write([]byte(email))
+ return base64.URLEncoding.EncodeToString(h.Sum(nil))
+}
+
+func (s *store) Set(email Email) error {
+ _, err := s.db.Exec(
+ `INSERT INTO emails (
+ id, email, sub_token, created_at, unsub_token, verified_at
+ )
+ VALUES
+ (?, ?, ?, ?, ?, ?)
+ ON CONFLICT (id) DO UPDATE SET
+ email=excluded.email,
+ sub_token=excluded.sub_token,
+ unsub_token=excluded.unsub_token,
+ verified_at=excluded.verified_at
+ `,
+ s.emailID(email.Email),
+ email.Email,
+ email.SubToken,
+ email.CreatedAt.Unix(),
+ email.UnsubToken,
+ sql.NullInt64{
+ Int64: email.VerifiedAt.Unix(),
+ Valid: !email.VerifiedAt.IsZero(),
+ },
+ )
+
+ return err
+}
+
+var scanCols = []string{
+ "email", "sub_token", "created_at", "unsub_token", "verified_at",
+}
+
+type row interface {
+ Scan(...interface{}) error
+}
+
+func (s *store) scanRow(row row) (Email, error) {
+ var email Email
+ var createdAt int64
+ var verifiedAt sql.NullInt64
+
+ err := row.Scan(
+ &email.Email,
+ &email.SubToken,
+ &createdAt,
+ &email.UnsubToken,
+ &verifiedAt,
+ )
+ if err != nil {
+ return Email{}, err
+ }
+
+ email.CreatedAt = time.Unix(createdAt, 0)
+ if verifiedAt.Valid {
+ email.VerifiedAt = time.Unix(verifiedAt.Int64, 0)
+ }
+
+ return email, nil
+}
+
+func (s *store) scanSingleRow(row *sql.Row) (Email, error) {
+ email, err := s.scanRow(row)
+ if errors.Is(err, sql.ErrNoRows) {
+ return Email{}, ErrNotFound
+ }
+
+ return email, err
+}
+
+func (s *store) Get(email string) (Email, error) {
+ row := s.db.QueryRow(
+ `SELECT `+strings.Join(scanCols, ",")+`
+ FROM emails
+ WHERE id=?`,
+ s.emailID(email),
+ )
+
+ return s.scanSingleRow(row)
+}
+
+func (s *store) GetBySubToken(subToken string) (Email, error) {
+ row := s.db.QueryRow(
+ `SELECT `+strings.Join(scanCols, ",")+`
+ FROM emails
+ WHERE sub_token=?`,
+ subToken,
+ )
+
+ return s.scanSingleRow(row)
+}
+
+func (s *store) GetByUnsubToken(unsubToken string) (Email, error) {
+ row := s.db.QueryRow(
+ `SELECT `+strings.Join(scanCols, ",")+`
+ FROM emails
+ WHERE unsub_token=?`,
+ unsubToken,
+ )
+
+ return s.scanSingleRow(row)
+}
+
+func (s *store) Delete(email string) error {
+ _, err := s.db.Exec(
+ `DELETE FROM emails WHERE id=?`,
+ s.emailID(email),
+ )
+ return err
+}
+
+func (s *store) GetAll() EmailIterator {
+ rows, err := s.db.Query(
+ `SELECT ` + strings.Join(scanCols, ",") + `
+ FROM emails`,
+ )
+
+ return func() (Email, error) {
+ if err != nil {
+ return Email{}, err
+
+ } else if !rows.Next() {
+ return Email{}, io.EOF
+ }
+ return s.scanRow(rows)
+ }
+}
+
+func (s *store) Close() error {
+ return s.db.Close()
+}
diff --git a/src/mailinglist/store_test.go b/src/mailinglist/store_test.go
new file mode 100644
index 0000000..9093d90
--- /dev/null
+++ b/src/mailinglist/store_test.go
@@ -0,0 +1,95 @@
+package mailinglist
+
+import (
+ "io"
+ "testing"
+ "time"
+
+ "github.com/mediocregopher/blog.mediocregopher.com/srv/cfg"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestStore(t *testing.T) {
+
+ var dataDir cfg.DataDir
+
+ if err := dataDir.Init(); err != nil {
+ t.Fatal(err)
+ }
+
+ t.Cleanup(func() { dataDir.Close() })
+
+ store, err := NewStore(dataDir)
+ assert.NoError(t, err)
+
+ t.Cleanup(func() {
+ assert.NoError(t, store.Close())
+ })
+
+ now := func() time.Time {
+ return time.Now().Truncate(time.Second)
+ }
+
+ assertGet := func(t *testing.T, email Email) {
+ t.Helper()
+
+ gotEmail, err := store.Get(email.Email)
+ assert.NoError(t, err)
+ assert.Equal(t, email, gotEmail)
+
+ gotEmail, err = store.GetBySubToken(email.SubToken)
+ assert.NoError(t, err)
+ assert.Equal(t, email, gotEmail)
+
+ if email.UnsubToken != "" {
+ gotEmail, err = store.GetByUnsubToken(email.UnsubToken)
+ assert.NoError(t, err)
+ assert.Equal(t, email, gotEmail)
+ }
+ }
+
+ assertNotFound := func(t *testing.T, email string) {
+ t.Helper()
+ _, err := store.Get(email)
+ assert.ErrorIs(t, err, ErrNotFound)
+ }
+
+ // now start actual tests
+
+ // GetAll should not do anything, there's no data
+ _, err = store.GetAll()()
+ assert.ErrorIs(t, err, io.EOF)
+
+ emailFoo := Email{
+ Email: "foo",
+ SubToken: "subTokenFoo",
+ CreatedAt: now(),
+ }
+
+ // email isn't stored yet, shouldn't exist
+ assertNotFound(t, emailFoo.Email)
+
+ // Set an email, now it should exist
+ assert.NoError(t, store.Set(emailFoo))
+ assertGet(t, emailFoo)
+
+ // Update the email with an unsub token
+ emailFoo.UnsubToken = "unsubTokenFoo"
+ emailFoo.VerifiedAt = now()
+ assert.NoError(t, store.Set(emailFoo))
+ assertGet(t, emailFoo)
+
+ // GetAll should now only return that email
+ iter := store.GetAll()
+ gotEmail, err := iter()
+ assert.NoError(t, err)
+ assert.Equal(t, emailFoo, gotEmail)
+ _, err = iter()
+ assert.ErrorIs(t, err, io.EOF)
+
+ // Delete the email, it should be gone
+ assert.NoError(t, store.Delete(emailFoo.Email))
+ assertNotFound(t, emailFoo.Email)
+ _, err = store.GetAll()()
+ assert.ErrorIs(t, err, io.EOF)
+}