summaryrefslogtreecommitdiff
path: root/srv/src/mailinglist/mailinglist.go
diff options
context:
space:
mode:
Diffstat (limited to 'srv/src/mailinglist/mailinglist.go')
-rw-r--r--srv/src/mailinglist/mailinglist.go272
1 files changed, 272 insertions, 0 deletions
diff --git a/srv/src/mailinglist/mailinglist.go b/srv/src/mailinglist/mailinglist.go
new file mode 100644
index 0000000..fc6e014
--- /dev/null
+++ b/srv/src/mailinglist/mailinglist.go
@@ -0,0 +1,272 @@
+// 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
+ if p.PublicURL, err = url.Parse(*publicURLStr); err != nil {
+ return fmt.Errorf("parsing -public-url: %w", err)
+ }
+
+ return nil
+ })
+}
+
+// Annotate implements mctx.Annotator interface.
+func (p *Params) Annotate(a mctx.Annotations) {
+ a["publicURL"] = 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.html?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.html?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
+}