diff options
Diffstat (limited to 'src/mailinglist')
-rw-r--r-- | src/mailinglist/mailer.go | 143 | ||||
-rw-r--r-- | src/mailinglist/mailinglist.go | 273 | ||||
-rw-r--r-- | src/mailinglist/store.go | 245 | ||||
-rw-r--r-- | src/mailinglist/store_test.go | 95 |
4 files changed, 0 insertions, 756 deletions
diff --git a/src/mailinglist/mailer.go b/src/mailinglist/mailer.go deleted file mode 100644 index 07d6c3a..0000000 --- a/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/src/mailinglist/mailinglist.go b/src/mailinglist/mailinglist.go deleted file mode 100644 index d9bdcc0..0000000 --- a/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/src/mailinglist/store.go b/src/mailinglist/store.go deleted file mode 100644 index 49e7617..0000000 --- a/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/src/mailinglist/store_test.go b/src/mailinglist/store_test.go deleted file mode 100644 index 9093d90..0000000 --- a/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) -} |