mediocre-blog/srv/mailinglist/mailinglist.go

273 lines
6.2 KiB
Go
Raw Normal View History

// 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("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
}