summaryrefslogtreecommitdiff
path: root/srv/src/mailinglist
diff options
context:
space:
mode:
Diffstat (limited to 'srv/src/mailinglist')
-rw-r--r--srv/src/mailinglist/mailer.go143
-rw-r--r--srv/src/mailinglist/mailinglist.go273
-rw-r--r--srv/src/mailinglist/store.go245
-rw-r--r--srv/src/mailinglist/store_test.go95
4 files changed, 0 insertions, 756 deletions
diff --git a/srv/src/mailinglist/mailer.go b/srv/src/mailinglist/mailer.go
deleted file mode 100644
index 07d6c3a..0000000
--- a/srv/src/mailinglist/mailer.go
+++ /dev/null
@@ -1,143 +0,0 @@
-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/srv/src/mailinglist/mailinglist.go b/srv/src/mailinglist/mailinglist.go
deleted file mode 100644
index d9bdcc0..0000000
--- a/srv/src/mailinglist/mailinglist.go
+++ /dev/null
@@ -1,273 +0,0 @@
-// 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/srv/src/mailinglist/store.go b/srv/src/mailinglist/store.go
deleted file mode 100644
index 49e7617..0000000
--- a/srv/src/mailinglist/store.go
+++ /dev/null
@@ -1,245 +0,0 @@
-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/srv/src/mailinglist/store_test.go b/srv/src/mailinglist/store_test.go
deleted file mode 100644
index 9093d90..0000000
--- a/srv/src/mailinglist/store_test.go
+++ /dev/null
@@ -1,95 +0,0 @@
-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)
-}