From 4f01edb9230f58ff84b0dd892c931ec8ac9aad55 Mon Sep 17 00:00:00 2001 From: Brian Picciano Date: Tue, 13 Sep 2022 12:56:08 +0200 Subject: move src out of srv, clean up default.nix and Makefile --- src/mailinglist/mailer.go | 143 +++++++++++++++++++++ src/mailinglist/mailinglist.go | 273 +++++++++++++++++++++++++++++++++++++++++ src/mailinglist/store.go | 245 ++++++++++++++++++++++++++++++++++++ src/mailinglist/store_test.go | 95 ++++++++++++++ 4 files changed, 756 insertions(+) create mode 100644 src/mailinglist/mailer.go create mode 100644 src/mailinglist/mailinglist.go create mode 100644 src/mailinglist/store.go create mode 100644 src/mailinglist/store_test.go (limited to 'src/mailinglist') 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) +} -- cgit v1.2.3