diff options
Diffstat (limited to 'srv/src/mailinglist/mailinglist.go')
-rw-r--r-- | srv/src/mailinglist/mailinglist.go | 272 |
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 +} |