273 lines
6.2 KiB
Go
273 lines
6.2 KiB
Go
// 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
|
|
}
|