add api endpoints for pow and mailinglist, plus some framework improvements
This commit is contained in:
parent
069ee93de1
commit
20b9213010
1
srv/.gitignore
vendored
Normal file
1
srv/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
mailinglist.sqlite3
|
@ -1,24 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
func internalServerError(rw http.ResponseWriter, r *http.Request, err error) {
|
|
||||||
http.Error(rw, "internal server error", 500)
|
|
||||||
log.Printf("%s %s: internal server error: %v", r.Method, r.URL, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func jsonResult(rw http.ResponseWriter, r *http.Request, v interface{}) {
|
|
||||||
b, err := json.Marshal(v)
|
|
||||||
if err != nil {
|
|
||||||
internalServerError(rw, r, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
b = append(b, '\n')
|
|
||||||
|
|
||||||
rw.Header().Set("Content-Type", "application/json")
|
|
||||||
rw.Write(b)
|
|
||||||
}
|
|
67
srv/cmd/mediocre-blog/mailinglist.go
Normal file
67
srv/cmd/mediocre-blog/mailinglist.go
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mediocregopher/blog.mediocregopher.com/srv/mailinglist"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mailingListSubscribeHandler(ml mailinglist.MailingList) http.Handler {
|
||||||
|
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
email := r.PostFormValue("email")
|
||||||
|
if parts := strings.Split(email, "@"); len(parts) != 2 ||
|
||||||
|
parts[0] == "" ||
|
||||||
|
parts[1] == "" ||
|
||||||
|
len(email) >= 512 {
|
||||||
|
badRequest(rw, r, errors.New("invalid email"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ml.BeginSubscription(email); errors.Is(err, mailinglist.ErrAlreadyVerified) {
|
||||||
|
// just eat the error, make it look to the user like the
|
||||||
|
// verification email was sent.
|
||||||
|
} else if err != nil {
|
||||||
|
internalServerError(rw, r, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func mailingListFinalizeHandler(ml mailinglist.MailingList) http.Handler {
|
||||||
|
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
subToken := r.PostFormValue("subToken")
|
||||||
|
if l := len(subToken); l == 0 || l > 128 {
|
||||||
|
badRequest(rw, r, errors.New("invalid subToken"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := ml.FinalizeSubscription(subToken)
|
||||||
|
if errors.Is(err, mailinglist.ErrNotFound) ||
|
||||||
|
errors.Is(err, mailinglist.ErrAlreadyVerified) {
|
||||||
|
badRequest(rw, r, err)
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
internalServerError(rw, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func mailingListUnsubscribeHandler(ml mailinglist.MailingList) http.Handler {
|
||||||
|
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
unsubToken := r.PostFormValue("unsubToken")
|
||||||
|
if l := len(unsubToken); l == 0 || l > 128 {
|
||||||
|
badRequest(rw, r, errors.New("invalid unsubToken"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := ml.Unsubscribe(unsubToken)
|
||||||
|
if errors.Is(err, mailinglist.ErrNotFound) {
|
||||||
|
badRequest(rw, r, err)
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
internalServerError(rw, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
@ -1,58 +1,140 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
"log"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"path"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/emersion/go-sasl"
|
||||||
|
"github.com/mediocregopher/blog.mediocregopher.com/srv/mailinglist"
|
||||||
"github.com/mediocregopher/blog.mediocregopher.com/srv/pow"
|
"github.com/mediocregopher/blog.mediocregopher.com/srv/pow"
|
||||||
|
"github.com/mediocregopher/mediocre-go-lib/v2/mctx"
|
||||||
|
"github.com/mediocregopher/mediocre-go-lib/v2/mlog"
|
||||||
"github.com/tilinna/clock"
|
"github.com/tilinna/clock"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func loggerFatalErr(ctx context.Context, logger *mlog.Logger, descr string, err error) {
|
||||||
|
logger.Fatal(ctx, fmt.Sprintf("%s: %v", descr, err))
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
||||||
|
logger := mlog.NewLogger(nil)
|
||||||
|
|
||||||
|
hostname := flag.String("hostname", "localhost:4000", "Hostname to advertise this server as")
|
||||||
staticDir := flag.String("static-dir", "", "Directory from which static files are served")
|
staticDir := flag.String("static-dir", "", "Directory from which static files are served")
|
||||||
listenAddr := flag.String("listen-addr", ":4000", "Address to listen for HTTP requests on")
|
listenAddr := flag.String("listen-addr", ":4000", "Address to listen for HTTP requests on")
|
||||||
|
dataDir := flag.String("data-dir", ".", "Directory to use for long term storage")
|
||||||
|
|
||||||
powTargetStr := flag.String("pow-target", "0x000FFFF", "Proof-of-work target, lower is more difficult")
|
powTargetStr := flag.String("pow-target", "0x000FFFF", "Proof-of-work target, lower is more difficult")
|
||||||
powSecret := flag.String("pow-secret", "", "Secret used to sign proof-of-work challenge seeds")
|
powSecret := flag.String("pow-secret", "", "Secret used to sign proof-of-work challenge seeds")
|
||||||
|
|
||||||
|
smtpAddr := flag.String("ml-smtp-addr", "", "Address of SMTP server to use for sending emails for the mailing list")
|
||||||
|
smtpAuthStr := flag.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.")
|
||||||
|
|
||||||
// parse config
|
// parse config
|
||||||
|
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case *staticDir == "":
|
case *staticDir == "":
|
||||||
log.Fatal("-static-dir is required")
|
logger.Fatal(context.Background(), "-static-dir is required")
|
||||||
case *powSecret == "":
|
case *powSecret == "":
|
||||||
log.Fatal("-pow-secret is required")
|
logger.Fatal(context.Background(), "-pow-secret is required")
|
||||||
|
case *smtpAddr == "":
|
||||||
|
logger.Fatal(context.Background(), "-ml-smtp-addr is required")
|
||||||
|
case *smtpAuthStr == "":
|
||||||
|
logger.Fatal(context.Background(), "-ml-smtp-auth is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
powTargetUint, err := strconv.ParseUint(*powTargetStr, 0, 32)
|
powTargetUint, err := strconv.ParseUint(*powTargetStr, 0, 32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("parsing -pow-target: %v", err)
|
loggerFatalErr(context.Background(), logger, "parsing -pow-target", err)
|
||||||
}
|
}
|
||||||
powTarget := uint32(powTargetUint)
|
powTarget := uint32(powTargetUint)
|
||||||
|
|
||||||
|
smtpAuthParts := strings.SplitN(*smtpAuthStr, ":", 2)
|
||||||
|
if len(smtpAuthParts) < 2 {
|
||||||
|
logger.Fatal(context.Background(), "invalid -ml-smtp-auth")
|
||||||
|
}
|
||||||
|
smtpAuth := sasl.NewPlainClient("", smtpAuthParts[0], smtpAuthParts[1])
|
||||||
|
smtpSendAs := smtpAuthParts[0]
|
||||||
|
|
||||||
// initialization
|
// initialization
|
||||||
|
|
||||||
|
ctx := mctx.Annotate(context.Background(),
|
||||||
|
"hostname", *hostname,
|
||||||
|
"staticDir", *staticDir,
|
||||||
|
"listenAddr", *listenAddr,
|
||||||
|
"dataDir", *dataDir,
|
||||||
|
"powTarget", fmt.Sprintf("%x", powTarget),
|
||||||
|
"smtpAddr", *smtpAddr,
|
||||||
|
"smtpSendAs", smtpSendAs,
|
||||||
|
)
|
||||||
|
|
||||||
clock := clock.Realtime()
|
clock := clock.Realtime()
|
||||||
|
|
||||||
powStore := pow.NewMemoryStore(clock)
|
powStore := pow.NewMemoryStore(clock)
|
||||||
defer powStore.Close()
|
defer powStore.Close()
|
||||||
|
|
||||||
mgr := pow.NewManager(pow.ManagerParams{
|
powMgr := pow.NewManager(pow.ManagerParams{
|
||||||
Clock: clock,
|
Clock: clock,
|
||||||
Store: powStore,
|
Store: powStore,
|
||||||
Secret: []byte(*powSecret),
|
Secret: []byte(*powSecret),
|
||||||
Target: powTarget,
|
Target: powTarget,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// sugar
|
||||||
|
requirePow := func(h http.Handler) http.Handler { return requirePowMiddleware(powMgr, h) }
|
||||||
|
|
||||||
|
mailer := mailinglist.NewMailer(mailinglist.MailerParams{
|
||||||
|
SMTPAddr: *smtpAddr,
|
||||||
|
SMTPAuth: smtpAuth,
|
||||||
|
SendAs: smtpSendAs,
|
||||||
|
})
|
||||||
|
|
||||||
|
mlStore, err := mailinglist.NewStore(path.Join(*dataDir, "mailinglist.sqlite3"))
|
||||||
|
if err != nil {
|
||||||
|
loggerFatalErr(ctx, logger, "initializing mailing list storage", err)
|
||||||
|
}
|
||||||
|
defer mlStore.Close()
|
||||||
|
|
||||||
|
ml := mailinglist.New(mailinglist.Params{
|
||||||
|
Store: mlStore,
|
||||||
|
Mailer: mailer,
|
||||||
|
Clock: clock,
|
||||||
|
FinalizeSubURL: *hostname + "/mailinglist/finalize.html",
|
||||||
|
UnsubURL: *hostname + "/mailinglist/unsubscribe.html",
|
||||||
|
})
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.Handle("/", http.FileServer(http.Dir(*staticDir)))
|
mux.Handle("/", http.FileServer(http.Dir(*staticDir)))
|
||||||
mux.Handle("/api/pow/challenge", newPowChallengeHandler(mgr))
|
|
||||||
|
apiMux := http.NewServeMux()
|
||||||
|
apiMux.Handle("/pow/challenge", newPowChallengeHandler(powMgr))
|
||||||
|
apiMux.Handle("/pow/check",
|
||||||
|
requirePow(
|
||||||
|
http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
apiMux.Handle("/mailinglist/subscribe", requirePow(mailingListSubscribeHandler(ml)))
|
||||||
|
apiMux.Handle("/mailinglist/finalize", mailingListFinalizeHandler(ml))
|
||||||
|
apiMux.Handle("/mailinglist/unsubscribe", mailingListUnsubscribeHandler(ml))
|
||||||
|
|
||||||
|
apiHandler := logMiddleware(logger.WithNamespace("api"), apiMux)
|
||||||
|
apiHandler = annotateMiddleware(apiHandler)
|
||||||
|
mux.Handle("/api/", http.StripPrefix("/api", apiHandler))
|
||||||
|
|
||||||
// run
|
// run
|
||||||
|
|
||||||
log.Printf("listening on %q", *listenAddr)
|
logger.Info(ctx, "listening")
|
||||||
log.Fatal(http.ListenAndServe(*listenAddr, mux))
|
|
||||||
|
// TODO graceful shutdown
|
||||||
|
err = http.ListenAndServe(*listenAddr, mux)
|
||||||
|
loggerFatalErr(ctx, logger, "listening", err)
|
||||||
}
|
}
|
||||||
|
69
srv/cmd/mediocre-blog/middleware.go
Normal file
69
srv/cmd/mediocre-blog/middleware.go
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mediocregopher/mediocre-go-lib/v2/mctx"
|
||||||
|
"github.com/mediocregopher/mediocre-go-lib/v2/mlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
func annotateMiddleware(h http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
|
type reqInfoKey string
|
||||||
|
|
||||||
|
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
ctx = mctx.Annotate(ctx,
|
||||||
|
reqInfoKey("remote_ip"), ip,
|
||||||
|
reqInfoKey("url"), r.URL,
|
||||||
|
reqInfoKey("method"), r.Method,
|
||||||
|
)
|
||||||
|
|
||||||
|
r = r.WithContext(ctx)
|
||||||
|
h.ServeHTTP(rw, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type logResponseWriter struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
statusCode int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLogResponseWriter(rw http.ResponseWriter) *logResponseWriter {
|
||||||
|
return &logResponseWriter{
|
||||||
|
ResponseWriter: rw,
|
||||||
|
statusCode: 200,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lrw *logResponseWriter) WriteHeader(statusCode int) {
|
||||||
|
lrw.statusCode = statusCode
|
||||||
|
lrw.ResponseWriter.WriteHeader(statusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func logMiddleware(logger *mlog.Logger, h http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
|
r = setRequestLogger(r, logger)
|
||||||
|
|
||||||
|
lrw := newLogResponseWriter(rw)
|
||||||
|
|
||||||
|
started := time.Now()
|
||||||
|
h.ServeHTTP(lrw, r)
|
||||||
|
took := time.Since(started)
|
||||||
|
|
||||||
|
type logCtxKey string
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
ctx = mctx.Annotate(ctx,
|
||||||
|
logCtxKey("took"), took.String(),
|
||||||
|
logCtxKey("response_code"), lrw.statusCode,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.Info(ctx, "handled HTTP request")
|
||||||
|
})
|
||||||
|
}
|
@ -2,6 +2,8 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/mediocregopher/blog.mediocregopher.com/srv/pow"
|
"github.com/mediocregopher/blog.mediocregopher.com/srv/pow"
|
||||||
@ -20,3 +22,28 @@ func newPowChallengeHandler(mgr pow.Manager) http.Handler {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func requirePowMiddleware(mgr pow.Manager, h http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
seedHex := r.PostFormValue("powSeed")
|
||||||
|
seed, err := hex.DecodeString(seedHex)
|
||||||
|
if err != nil || len(seed) == 0 {
|
||||||
|
badRequest(rw, r, errors.New("invalid powSeed"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
solutionHex := r.PostFormValue("powSolution")
|
||||||
|
solution, err := hex.DecodeString(solutionHex)
|
||||||
|
if err != nil || len(seed) == 0 {
|
||||||
|
badRequest(rw, r, errors.New("invalid powSolution"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mgr.CheckSolution(seed, solution); err != nil {
|
||||||
|
badRequest(rw, r, fmt.Errorf("checking proof-of-work solution: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.ServeHTTP(rw, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
60
srv/cmd/mediocre-blog/utils.go
Normal file
60
srv/cmd/mediocre-blog/utils.go
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/mediocregopher/mediocre-go-lib/v2/mlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
type loggerCtxKey int
|
||||||
|
|
||||||
|
func setRequestLogger(r *http.Request, logger *mlog.Logger) *http.Request {
|
||||||
|
ctx := r.Context()
|
||||||
|
ctx = context.WithValue(ctx, loggerCtxKey(0), logger)
|
||||||
|
return r.WithContext(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getRequestLogger(r *http.Request) *mlog.Logger {
|
||||||
|
ctx := r.Context()
|
||||||
|
logger, _ := ctx.Value(loggerCtxKey(0)).(*mlog.Logger)
|
||||||
|
if logger == nil {
|
||||||
|
logger = mlog.Null
|
||||||
|
}
|
||||||
|
return logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonResult(rw http.ResponseWriter, r *http.Request, v interface{}) {
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
internalServerError(rw, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b = append(b, '\n')
|
||||||
|
|
||||||
|
rw.Header().Set("Content-Type", "application/json")
|
||||||
|
rw.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func badRequest(rw http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
getRequestLogger(r).Warn(r.Context(), "bad request", err)
|
||||||
|
|
||||||
|
rw.WriteHeader(400)
|
||||||
|
jsonResult(rw, r, struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}{
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func internalServerError(rw http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
getRequestLogger(r).Error(r.Context(), "internal server error", err)
|
||||||
|
|
||||||
|
rw.WriteHeader(500)
|
||||||
|
jsonResult(rw, r, struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}{
|
||||||
|
Error: "internal server error",
|
||||||
|
})
|
||||||
|
}
|
@ -7,6 +7,7 @@ require (
|
|||||||
github.com/emersion/go-smtp v0.15.0
|
github.com/emersion/go-smtp v0.15.0
|
||||||
github.com/google/uuid v1.3.0
|
github.com/google/uuid v1.3.0
|
||||||
github.com/mattn/go-sqlite3 v1.14.8
|
github.com/mattn/go-sqlite3 v1.14.8
|
||||||
|
github.com/mediocregopher/mediocre-go-lib/v2 v2.0.0-beta.0
|
||||||
github.com/rubenv/sql-migrate v0.0.0-20210614095031-55d5740dbbcc
|
github.com/rubenv/sql-migrate v0.0.0-20210614095031-55d5740dbbcc
|
||||||
github.com/stretchr/testify v1.7.0
|
github.com/stretchr/testify v1.7.0
|
||||||
github.com/tilinna/clock v1.1.0
|
github.com/tilinna/clock v1.1.0
|
||||||
|
@ -104,6 +104,8 @@ github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A
|
|||||||
github.com/mattn/go-sqlite3 v1.14.8 h1:gDp86IdQsN/xWjIEmr9MF6o9mpksUgh0fu+9ByFxzIU=
|
github.com/mattn/go-sqlite3 v1.14.8 h1:gDp86IdQsN/xWjIEmr9MF6o9mpksUgh0fu+9ByFxzIU=
|
||||||
github.com/mattn/go-sqlite3 v1.14.8/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
github.com/mattn/go-sqlite3 v1.14.8/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||||
|
github.com/mediocregopher/mediocre-go-lib/v2 v2.0.0-beta.0 h1:i9FBkcCaWXxteJ8458AD8dBL2YqSxVlpsHOMWg5N9Dc=
|
||||||
|
github.com/mediocregopher/mediocre-go-lib/v2 v2.0.0-beta.0/go.mod h1:wOZVlnKYvIbkzyCJ3dxy1k40XkirvCd1pisX2O91qoQ=
|
||||||
github.com/mitchellh/cli v1.1.2/go.mod h1:6iaV0fGdElS6dPBx0EApTxHrcWvmJphyh2n8YBLPPZ4=
|
github.com/mitchellh/cli v1.1.2/go.mod h1:6iaV0fGdElS6dPBx0EApTxHrcWvmJphyh2n8YBLPPZ4=
|
||||||
github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw=
|
github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw=
|
||||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||||
|
@ -51,6 +51,11 @@ type Params struct {
|
|||||||
UnsubURL string
|
UnsubURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// New initializes and returns a MailingList instance using the given Params.
|
||||||
|
func New(params Params) MailingList {
|
||||||
|
return &mailingList{params: params}
|
||||||
|
}
|
||||||
|
|
||||||
type mailingList struct {
|
type mailingList struct {
|
||||||
params Params
|
params Params
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user