@@ -0,0 +1,472 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/zerodha/simplesessions/stores/postgres/v3"
|
||||
"github.com/zerodha/simplesessions/v3"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
type OIDCclaim struct {
|
||||
Email string `json:"email"`
|
||||
EmailVerified bool `json:"email_verified"`
|
||||
Sub string `json:"sub"`
|
||||
Picture string `json:"picture"`
|
||||
Name string `json:"name"`
|
||||
PreferredUsername string `json:"preferred_username"`
|
||||
}
|
||||
|
||||
type OIDCConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
ProviderURL string `json:"provider_url"`
|
||||
RedirectURL string `json:"redirect_url"`
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
AutoCreateUsers bool `json:"auto_create_users"`
|
||||
DefaultUserRoleID int `json:"default_user_role_id"`
|
||||
DefaultListRoleID int `json:"default_list_role_id"`
|
||||
}
|
||||
|
||||
type BasicAuthConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
OIDC OIDCConfig
|
||||
BasicAuth BasicAuthConfig
|
||||
}
|
||||
|
||||
// Callbacks takes two callback functions required by simplesessions.
|
||||
type Callbacks struct {
|
||||
SetCookie func(cookie *http.Cookie, w any) error
|
||||
GetCookie func(name string, r any) (*http.Cookie, error)
|
||||
GetUser func(id int) (User, error)
|
||||
}
|
||||
|
||||
type Auth struct {
|
||||
apiUsers map[string]User
|
||||
sync.RWMutex
|
||||
|
||||
cfg Config
|
||||
oauthCfg oauth2.Config
|
||||
verifier *oidc.IDTokenVerifier
|
||||
provider *oidc.Provider
|
||||
sess *simplesessions.Manager
|
||||
sessStore *postgres.Store
|
||||
cb *Callbacks
|
||||
log *log.Logger
|
||||
}
|
||||
|
||||
var sessPruneInterval = time.Hour * 12
|
||||
|
||||
// New returns an initialize Auth instance.
|
||||
func New(cfg Config, db *sql.DB, cb *Callbacks, lo *log.Logger) (*Auth, error) {
|
||||
a := &Auth{
|
||||
cfg: cfg,
|
||||
cb: cb,
|
||||
log: lo,
|
||||
|
||||
apiUsers: map[string]User{},
|
||||
}
|
||||
|
||||
// Initialize session manager.
|
||||
a.sess = simplesessions.New(simplesessions.Options{
|
||||
EnableAutoCreate: false,
|
||||
SessionIDLength: 64,
|
||||
Cookie: simplesessions.CookieOptions{
|
||||
IsHTTPOnly: true,
|
||||
MaxAge: time.Hour * 24 * 7,
|
||||
},
|
||||
})
|
||||
st, err := postgres.New(postgres.Opt{}, db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.sessStore = st
|
||||
a.sess.UseStore(st)
|
||||
a.sess.SetCookieHooks(cb.GetCookie, cb.SetCookie)
|
||||
|
||||
// Prune dead sessions from the DB periodically.
|
||||
go func() {
|
||||
if err := st.Prune(); err != nil {
|
||||
lo.Printf("error pruning login sessions: %v", err)
|
||||
}
|
||||
time.Sleep(sessPruneInterval)
|
||||
}()
|
||||
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// CacheAPIUsers caches API users for authenticating requests. It wipes
|
||||
// the existing cache every time and is meant for syncing all API users
|
||||
// in the database in one shot.
|
||||
func (o *Auth) CacheAPIUsers(users []User) {
|
||||
o.Lock()
|
||||
defer o.Unlock()
|
||||
|
||||
o.apiUsers = map[string]User{}
|
||||
for _, u := range users {
|
||||
o.apiUsers[u.Username] = u
|
||||
}
|
||||
}
|
||||
|
||||
// CacheAPIUser caches an API user for authenticating requests.
|
||||
func (o *Auth) CacheAPIUser(u User) {
|
||||
o.Lock()
|
||||
o.apiUsers[u.Username] = u
|
||||
o.Unlock()
|
||||
}
|
||||
|
||||
// GetAPIToken validates an API user+token.
|
||||
func (o *Auth) GetAPIToken(user string, token string) (User, bool) {
|
||||
o.RLock()
|
||||
t, ok := o.apiUsers[user]
|
||||
o.RUnlock()
|
||||
|
||||
if !ok || subtle.ConstantTimeCompare([]byte(t.Password.String), []byte(HashAPIToken(token))) != 1 {
|
||||
return User{}, false
|
||||
}
|
||||
|
||||
return t, true
|
||||
}
|
||||
|
||||
// initOIDC initializes the OIDC provider, verifier, and OAuth config.
|
||||
func (o *Auth) initOIDC() error {
|
||||
if !o.cfg.OIDC.Enabled {
|
||||
return fmt.Errorf("OIDC is not enabled")
|
||||
}
|
||||
|
||||
provider, err := oidc.NewProvider(context.Background(), o.cfg.OIDC.ProviderURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error initializing OIDC OAuth provider: %v", err)
|
||||
}
|
||||
|
||||
o.verifier = provider.Verifier(&oidc.Config{
|
||||
ClientID: o.cfg.OIDC.ClientID,
|
||||
})
|
||||
|
||||
o.oauthCfg = oauth2.Config{
|
||||
ClientID: o.cfg.OIDC.ClientID,
|
||||
ClientSecret: o.cfg.OIDC.ClientSecret,
|
||||
Endpoint: provider.Endpoint(),
|
||||
RedirectURL: o.cfg.OIDC.RedirectURL,
|
||||
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
|
||||
}
|
||||
o.provider = provider
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getProvider returns the OIDC provider, initializing it if necessary.
|
||||
func (o *Auth) getProvider() (*oidc.Provider, error) {
|
||||
o.Lock()
|
||||
defer o.Unlock()
|
||||
|
||||
if o.provider == nil {
|
||||
if err := o.initOIDC(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return o.provider, nil
|
||||
}
|
||||
|
||||
// getVerifier returns the OIDC verifier, initializing it if necessary.
|
||||
func (o *Auth) getVerifier() (*oidc.IDTokenVerifier, error) {
|
||||
o.Lock()
|
||||
defer o.Unlock()
|
||||
|
||||
if o.verifier == nil {
|
||||
if err := o.initOIDC(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return o.verifier, nil
|
||||
}
|
||||
|
||||
// getOAuthConfig returns the OAuth config, initializing it if necessary.
|
||||
func (o *Auth) getOAuthConfig() (*oauth2.Config, error) {
|
||||
o.Lock()
|
||||
defer o.Unlock()
|
||||
|
||||
if o.oauthCfg.ClientID == "" {
|
||||
if err := o.initOIDC(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &o.oauthCfg, nil
|
||||
}
|
||||
|
||||
// GetOIDCAuthURL returns the OIDC provider's auth URL to redirect to.
|
||||
func (o *Auth) GetOIDCAuthURL(state, nonce string) string {
|
||||
cfg, err := o.getOAuthConfig()
|
||||
if err != nil {
|
||||
o.log.Printf("error getting OAuth config: %v", err)
|
||||
return ""
|
||||
}
|
||||
return cfg.AuthCodeURL(state, oidc.Nonce(nonce))
|
||||
}
|
||||
|
||||
// ExchangeOIDCToken takes an OIDC authorization code (recieved via redirect from the OIDC provider),
|
||||
// validates it, and returns an OIDC token for subsequent auth.
|
||||
func (o *Auth) ExchangeOIDCToken(code, nonce string) (string, OIDCclaim, error) {
|
||||
cfg, err := o.getOAuthConfig()
|
||||
if err != nil {
|
||||
return "", OIDCclaim{}, echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("error getting OAuth config: %v", err))
|
||||
}
|
||||
|
||||
tk, err := cfg.Exchange(context.TODO(), code)
|
||||
if err != nil {
|
||||
return "", OIDCclaim{}, echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("error exchanging token: %v", err))
|
||||
}
|
||||
|
||||
rawIDTk, ok := tk.Extra("id_token").(string)
|
||||
if !ok {
|
||||
return "", OIDCclaim{}, echo.NewHTTPError(http.StatusUnauthorized, "`id_token` missing.")
|
||||
}
|
||||
|
||||
verifier, err := o.getVerifier()
|
||||
if err != nil {
|
||||
return "", OIDCclaim{}, echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("error getting verifier: %v", err))
|
||||
}
|
||||
|
||||
idTk, err := verifier.Verify(context.TODO(), rawIDTk)
|
||||
if err != nil {
|
||||
return "", OIDCclaim{}, echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("error verifying ID token: %v", err))
|
||||
}
|
||||
|
||||
if idTk.Nonce != nonce {
|
||||
return "", OIDCclaim{}, echo.NewHTTPError(http.StatusUnauthorized, "nonce did not match")
|
||||
}
|
||||
|
||||
var claims OIDCclaim
|
||||
if err := idTk.Claims(&claims); err != nil {
|
||||
return "", OIDCclaim{}, errors.New("error getting user from OIDC")
|
||||
}
|
||||
|
||||
// If claims doesn't have the e-mail, attempt to fetch it from the userinfo endpoint.
|
||||
if claims.Email == "" {
|
||||
provider, err := o.getProvider()
|
||||
if err != nil {
|
||||
return "", OIDCclaim{}, fmt.Errorf("error getting provider: %v", err)
|
||||
}
|
||||
|
||||
userInfo, err := provider.UserInfo(context.TODO(), oauth2.StaticTokenSource(tk))
|
||||
if err != nil {
|
||||
return "", OIDCclaim{}, errors.New("error fetching user info from OIDC")
|
||||
}
|
||||
|
||||
// Parse the UserInfo claims into the claims struct
|
||||
if err := userInfo.Claims(&claims); err != nil {
|
||||
return "", OIDCclaim{}, errors.New("error parsing user info claims")
|
||||
}
|
||||
}
|
||||
|
||||
return rawIDTk, claims, nil
|
||||
}
|
||||
|
||||
// Middleware is the HTTP middleware used for wrapping HTTP handlers registered on the echo router.
|
||||
// It authorizes token (BasicAuth/token) based and cookie based sessions and on successful auth,
|
||||
// sets the authenticated User{} on the echo context on the key UserKey. On failure, it sets an Error{}
|
||||
// instead on the same key.
|
||||
func (o *Auth) Middleware(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
// It's an `Authorization` header request.
|
||||
hdr := strings.TrimSpace(c.Request().Header.Get("Authorization"))
|
||||
|
||||
// If cookie is set, ignore BasicAuth. This is to preserve backwards compatibility
|
||||
// in v3 -> v4 upgrade where the user browser sessions would still have old
|
||||
// BasicAuth credentials, which no longer work in the new system which expects
|
||||
// session cookies instead, which causes a redirect loop despite loggin in and session
|
||||
// cookies being set.
|
||||
//
|
||||
// TODO: This should be removed in a future version.
|
||||
if c := strings.TrimSpace(c.Request().Header.Get("Cookie")); strings.Contains(c, "session=") {
|
||||
hdr = ""
|
||||
}
|
||||
|
||||
if len(hdr) > 0 {
|
||||
key, token, err := parseAuthHeader(hdr)
|
||||
if err != nil {
|
||||
c.Set(UserHTTPCtxKey, echo.NewHTTPError(http.StatusForbidden, err.Error()))
|
||||
return next(c)
|
||||
}
|
||||
|
||||
// Validate the token.
|
||||
user, ok := o.GetAPIToken(key, token)
|
||||
if !ok {
|
||||
c.Set(UserHTTPCtxKey, echo.NewHTTPError(http.StatusForbidden, "invalid API credentials"))
|
||||
return next(c)
|
||||
}
|
||||
|
||||
// Set the user details on the handler context.
|
||||
c.Set(UserHTTPCtxKey, user)
|
||||
return next(c)
|
||||
}
|
||||
|
||||
// Is it a cookie based session?
|
||||
sess, user, err := o.validateSession(c)
|
||||
if err != nil {
|
||||
c.Set(UserHTTPCtxKey, echo.NewHTTPError(http.StatusForbidden, "invalid session"))
|
||||
return next(c)
|
||||
}
|
||||
|
||||
// Set the user details on the handler context.
|
||||
c.Set(UserHTTPCtxKey, user)
|
||||
c.Set(SessionKey, sess)
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
|
||||
// Perm is an HTTP handler middleware that checks if the authenticated user has the required permissions.
|
||||
func (o *Auth) Perm(next echo.HandlerFunc, perms ...string) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
u, ok := c.Get(UserHTTPCtxKey).(User)
|
||||
if !ok {
|
||||
c.Set(UserHTTPCtxKey, echo.NewHTTPError(http.StatusForbidden, "invalid session"))
|
||||
return next(c)
|
||||
}
|
||||
|
||||
// If the current user is a Super Admin user, do no checks.
|
||||
if u.UserRole.ID == SuperAdminRoleID {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
// Check if the current handler's permission is in the user's permission map.
|
||||
var (
|
||||
has = false
|
||||
perm = ""
|
||||
)
|
||||
for _, perm = range perms {
|
||||
if _, ok := u.PermissionsMap[perm]; ok {
|
||||
has = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !has {
|
||||
return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("permission denied: %s", perm))
|
||||
}
|
||||
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
|
||||
// SaveSession creates and sets a session (post successful login/auth).
|
||||
func (o *Auth) SaveSession(u User, oidcToken string, c echo.Context) error {
|
||||
sess, err := o.sess.NewSession(c, c)
|
||||
if err != nil {
|
||||
o.log.Printf("error creating login session: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "error creating session")
|
||||
}
|
||||
|
||||
if err := sess.SetMulti(map[string]any{"user_id": u.ID, "oidc_token": oidcToken}); err != nil {
|
||||
o.log.Printf("error setting login session: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "error creating session")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSessionID returns the current session ID from the echo context.
|
||||
func GetSessionID(c echo.Context) string {
|
||||
sess, ok := c.Get(SessionKey).(*simplesessions.Session)
|
||||
if !ok || sess == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return sess.ID()
|
||||
}
|
||||
|
||||
// HashAPIToken returns the SHA-256 hex digest of an API token.
|
||||
func HashAPIToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// validateSession checks if the cookie session is valid (in the DB) and returns the session and user details.
|
||||
func (o *Auth) validateSession(c echo.Context) (*simplesessions.Session, User, error) {
|
||||
// Cookie session.
|
||||
sess, err := o.sess.Acquire(context.TODO(), c, c)
|
||||
if err != nil {
|
||||
return nil, User{}, echo.NewHTTPError(http.StatusForbidden, err.Error())
|
||||
}
|
||||
|
||||
// Get the session variables.
|
||||
vars, err := sess.GetMulti("user_id", "oidc_token")
|
||||
if err != nil {
|
||||
return nil, User{}, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
// Validate the user ID in the session.
|
||||
userID, err := o.sessStore.Int(vars["user_id"], nil)
|
||||
if err != nil || userID < 1 {
|
||||
o.log.Printf("error fetching session user ID: %v", err)
|
||||
return nil, User{}, echo.NewHTTPError(http.StatusInternalServerError, "invalid session.")
|
||||
}
|
||||
|
||||
// Fetch user details from the database.
|
||||
user, err := o.cb.GetUser(userID)
|
||||
if err != nil {
|
||||
o.log.Printf("error fetching session user: %v", err)
|
||||
}
|
||||
|
||||
return sess, user, err
|
||||
}
|
||||
|
||||
// GetUser retrieves and returns the User object from an authenticated
|
||||
// HTTP handler request.
|
||||
func GetUser(c echo.Context) User {
|
||||
return c.Get(UserHTTPCtxKey).(User)
|
||||
}
|
||||
|
||||
// parseAuthHeader parses the Authorization header and returns the api_key and access_token.
|
||||
func parseAuthHeader(h string) (string, string, error) {
|
||||
const authBasic = "Basic"
|
||||
const authToken = "token"
|
||||
|
||||
var (
|
||||
pair []string
|
||||
delim = ":"
|
||||
)
|
||||
|
||||
if strings.HasPrefix(h, authToken) {
|
||||
// token api_key:access_token.
|
||||
pair = strings.SplitN(strings.Trim(h[len(authToken):], " "), delim, 2)
|
||||
} else if strings.HasPrefix(h, authBasic) {
|
||||
// HTTP BasicAuth. This is supported for backwards compatibility.
|
||||
payload, err := base64.StdEncoding.DecodeString(string(strings.Trim(h[len(authBasic):], " ")))
|
||||
if err != nil {
|
||||
return "", "", echo.NewHTTPError(http.StatusBadRequest, "invalid Base64 value in Basic Authorization header")
|
||||
}
|
||||
pair = strings.SplitN(string(payload), delim, 2)
|
||||
} else {
|
||||
return "", "", echo.NewHTTPError(http.StatusBadRequest, "unknown Authorization scheme")
|
||||
}
|
||||
|
||||
if len(pair) < 2 {
|
||||
return "", "", echo.NewHTTPError(http.StatusBadRequest, "api_key:token missing")
|
||||
}
|
||||
|
||||
if len(pair[0]) == 0 || len(pair[1]) == 0 {
|
||||
return "", "", echo.NewHTTPError(http.StatusBadRequest, "empty `api_key` or `token`")
|
||||
}
|
||||
|
||||
return pair[0], pair[1], nil
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/lib/pq"
|
||||
null "gopkg.in/volatiletech/null.v6"
|
||||
)
|
||||
|
||||
var ErrPermDenied = echo.NewHTTPError(http.StatusForbidden, "permission denied")
|
||||
|
||||
// PermType indicates a generic permission type which is either get (read) or manage (write).
|
||||
type PermType uint8
|
||||
|
||||
const (
|
||||
PermTypeGet PermType = 1 << iota
|
||||
PermTypeManage
|
||||
)
|
||||
|
||||
const (
|
||||
// UserHTTPCtxKey is the key on which the User profile is set on echo handlers.
|
||||
UserHTTPCtxKey = "auth_user"
|
||||
SessionKey = "auth_session"
|
||||
)
|
||||
|
||||
const (
|
||||
// SuperAdminRoleID is the database ID of the primordial super admin role.
|
||||
SuperAdminRoleID = 1
|
||||
|
||||
// User.
|
||||
UserTypeUser = "user"
|
||||
UserTypeAPI = "api"
|
||||
UserStatusEnabled = "enabled"
|
||||
UserStatusDisabled = "disabled"
|
||||
|
||||
// Role.
|
||||
RoleTypeUser = "user"
|
||||
RoleTypeList = "list"
|
||||
)
|
||||
|
||||
// List of all granular permissions.
|
||||
const (
|
||||
PermListGetAll = "lists:get_all"
|
||||
PermListManageAll = "lists:manage_all"
|
||||
PermListManage = "list:manage"
|
||||
PermListGet = "list:get"
|
||||
PermSubscribersGet = "subscribers:get"
|
||||
PermSubscribersGetAll = "subscribers:get_all"
|
||||
PermSubscribersManage = "subscribers:manage"
|
||||
PermSubscribersImport = "subscribers:import"
|
||||
PermSubscribersSqlQuery = "subscribers:sql_query"
|
||||
PermTxSend = "tx:send"
|
||||
PermCampaignsGet = "campaigns:get"
|
||||
PermCampaignsGetAll = "campaigns:get_all"
|
||||
PermCampaignsGetAnalytics = "campaigns:get_analytics"
|
||||
PermCampaignsManage = "campaigns:manage"
|
||||
PermCampaignsManageAll = "campaigns:manage_all"
|
||||
PermCampaignsSend = "campaigns:send"
|
||||
PermBouncesGet = "bounces:get"
|
||||
PermBouncesManage = "bounces:manage"
|
||||
PermWebhooksPostBounce = "webhooks:post_bounce"
|
||||
PermMediaGet = "media:get"
|
||||
PermMediaManage = "media:manage"
|
||||
PermTemplatesGet = "templates:get"
|
||||
PermTemplatesManage = "templates:manage"
|
||||
PermUsersGet = "users:get"
|
||||
PermUsersManage = "users:manage"
|
||||
PermRolesGet = "roles:get"
|
||||
PermRolesManage = "roles:manage"
|
||||
PermSettingsGet = "settings:get"
|
||||
PermSettingsManage = "settings:manage"
|
||||
PermSettingsMaintain = "settings:maintain"
|
||||
)
|
||||
|
||||
// Base holds common fields shared across models.
|
||||
type Base struct {
|
||||
ID int `db:"id" json:"id"`
|
||||
CreatedAt null.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt null.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// User represents an admin user.
|
||||
type User struct {
|
||||
Base
|
||||
|
||||
Username string `db:"username" json:"username"`
|
||||
|
||||
// For API users, this stores the SHA-256 token hash.
|
||||
Password null.String `db:"password" json:"password,omitempty"`
|
||||
|
||||
PasswordLogin bool `db:"password_login" json:"password_login"`
|
||||
Email null.String `db:"email" json:"email"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Type string `db:"type" json:"type"`
|
||||
Status string `db:"status" json:"status"`
|
||||
Avatar null.String `db:"avatar" json:"avatar"`
|
||||
TwofaType string `db:"twofa_type" json:"twofa_type"`
|
||||
TwofaKey null.String `db:"twofa_key" json:"-"`
|
||||
LoggedInAt null.Time `db:"loggedin_at" json:"loggedin_at"`
|
||||
UserRoleID int `db:"user_role_id" json:"user_role_id,omitempty"`
|
||||
UserRoleName string `db:"user_role_name" json:"-"`
|
||||
ListRoleID *int `db:"list_role_id" json:"list_role_id,omitempty"`
|
||||
ListRoleName null.String `db:"list_role_name" json:"-"`
|
||||
UserRolePerms pq.StringArray `db:"user_role_permissions" json:"-"`
|
||||
ListsPermsRaw *json.RawMessage `db:"list_role_perms" json:"-"`
|
||||
|
||||
// Non-DB fields filled post-retrieval.
|
||||
UserRole struct {
|
||||
ID int `db:"-" json:"id"`
|
||||
Name string `db:"-" json:"name"`
|
||||
Permissions []string `db:"-" json:"permissions"`
|
||||
} `db:"-" json:"user_role"`
|
||||
|
||||
ListRole *ListRolePermissions `db:"-" json:"list_role"`
|
||||
PermissionsMap map[string]struct{} `db:"-" json:"-"`
|
||||
ListPermissionsMap map[int]map[string]struct{} `db:"-" json:"-"`
|
||||
GetListIDs []int `db:"-" json:"-"`
|
||||
ManageListIDs []int `db:"-" json:"-"`
|
||||
HasPassword bool `db:"-" json:"-"`
|
||||
}
|
||||
|
||||
type ListPermission struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Permissions pq.StringArray `json:"permissions"`
|
||||
}
|
||||
|
||||
type ListRolePermissions struct {
|
||||
ID int `db:"-" json:"id"`
|
||||
Name string `db:"-" json:"name"`
|
||||
Lists []ListPermission `db:"-" json:"lists"`
|
||||
}
|
||||
|
||||
type Role struct {
|
||||
Base
|
||||
|
||||
Type string `db:"type" json:"type"`
|
||||
Name null.String `db:"name" json:"name"`
|
||||
Permissions pq.StringArray `db:"permissions" json:"permissions"`
|
||||
|
||||
ListID null.Int `db:"list_id" json:"-"`
|
||||
ParentID null.Int `db:"parent_id" json:"-"`
|
||||
ListsRaw json.RawMessage `db:"list_permissions" json:"-"`
|
||||
Lists []ListPermission `db:"-" json:"lists"`
|
||||
}
|
||||
|
||||
type ListRole struct {
|
||||
Base
|
||||
|
||||
Name null.String `db:"name" json:"name"`
|
||||
|
||||
ListID null.Int `db:"list_id" json:"-"`
|
||||
ParentID null.Int `db:"parent_id" json:"-"`
|
||||
ListsRaw json.RawMessage `db:"list_permissions" json:"-"`
|
||||
Lists []ListPermission `db:"-" json:"lists"`
|
||||
}
|
||||
|
||||
// HasPerm checks if the user has a specific permission.
|
||||
func (u *User) HasPerm(perm string) bool {
|
||||
// Short-circuit if the user is the primordial super admin.
|
||||
if u.UserRoleID == SuperAdminRoleID {
|
||||
return true
|
||||
}
|
||||
|
||||
_, ok := u.PermissionsMap[perm]
|
||||
return ok
|
||||
}
|
||||
|
||||
// HasListPerm checks if the user has get or manage access to the given list.
|
||||
// perm is either PermGet or PermManage.
|
||||
func (u *User) HasListPerm(types PermType, listIDs ...int) error {
|
||||
var permAll, perm string
|
||||
|
||||
if types == 0 {
|
||||
return ErrPermDenied
|
||||
}
|
||||
|
||||
if types&PermTypeGet != 0 {
|
||||
permAll = PermListGetAll
|
||||
perm = PermListGet
|
||||
} else if types&PermTypeManage != 0 {
|
||||
permAll = PermListManageAll
|
||||
perm = PermListManage
|
||||
}
|
||||
|
||||
// Check if the user has permissions for all lists or the specific list.
|
||||
if u.HasPerm(permAll) {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, id := range listIDs {
|
||||
if id > 0 {
|
||||
if !u.hasListPerm(perm, id) {
|
||||
return ErrPermDenied
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *User) hasListPerm(perm string, listID int) bool {
|
||||
// Short-circuit if the user is the primordial super admin.
|
||||
if u.UserRoleID == SuperAdminRoleID {
|
||||
return true
|
||||
}
|
||||
|
||||
if _, ok := u.ListPermissionsMap[listID]; !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
_, ok := u.ListPermissionsMap[listID][perm]
|
||||
return ok
|
||||
}
|
||||
|
||||
// GetPermittedLists returns a list of IDs the user has access to based on
|
||||
// the given get / manage permissions. If the user has the blanket "*_all"
|
||||
// permission (or the user is a super admin), then the bool is set to true and
|
||||
// the list is nil as all lists are permitted.
|
||||
func (u *User) GetPermittedLists(types PermType) (bool, []int) {
|
||||
if types == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Short-circuit if the user is the primordial super admin.
|
||||
if u.UserRoleID == SuperAdminRoleID {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
var (
|
||||
get = types&PermTypeGet != 0
|
||||
manage = types&PermTypeManage != 0
|
||||
)
|
||||
|
||||
// If the user has the list:get_all or list:manage_all permission, no
|
||||
// further checks are required.
|
||||
if get {
|
||||
if _, ok := u.PermissionsMap[PermListGetAll]; ok {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
if manage {
|
||||
if _, ok := u.PermissionsMap[PermListManageAll]; ok {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
if get {
|
||||
// If the user has per-list permissions, return that. Otherwise, let the
|
||||
// 'manage' permission check run.
|
||||
if len(u.GetListIDs) > 0 {
|
||||
out := make([]int, len(u.GetListIDs))
|
||||
copy(out, u.GetListIDs)
|
||||
return false, out
|
||||
}
|
||||
}
|
||||
|
||||
if manage {
|
||||
// User has per-list permissions.
|
||||
out := make([]int, len(u.ManageListIDs))
|
||||
copy(out, u.ManageListIDs)
|
||||
return false, out
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// FilterListsByPerm returns list IDs filtered by either of the given perms.
|
||||
func (u *User) FilterListsByPerm(types PermType, listIDs []int) []int {
|
||||
if types == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
get = types&PermTypeGet != 0
|
||||
manage = types&PermTypeManage != 0
|
||||
)
|
||||
|
||||
// If the user has full list management permission,
|
||||
// no further checks are required.
|
||||
if get {
|
||||
if _, ok := u.PermissionsMap[PermListGetAll]; ok {
|
||||
return listIDs
|
||||
}
|
||||
}
|
||||
if manage {
|
||||
if _, ok := u.PermissionsMap[PermListManageAll]; ok {
|
||||
return listIDs
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]int, 0, len(listIDs))
|
||||
for _, id := range listIDs {
|
||||
// Check if it exists in the map.
|
||||
if l, ok := u.ListPermissionsMap[id]; ok {
|
||||
// Check if any of the given permission exists for it.
|
||||
if get {
|
||||
if _, ok := l[PermListGet]; ok {
|
||||
out = append(out, id)
|
||||
}
|
||||
} else if manage {
|
||||
if _, ok := l[PermListManage]; ok {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// GetPermittedListIDs filters the given list IDs by the user's get/manage
|
||||
// permissions and returns the filtered set. Unlike `FilterListsByPerm()`, if no
|
||||
// IDs are present (empty input or 0 permitted lists), it falls back to the user's
|
||||
// permitted list IDs if any. This is useful for endpoints which accept a few IDs
|
||||
// or the lack of which implies "all".
|
||||
func (u *User) GetPermittedListIDs(listIDs []int) []int {
|
||||
listIDs = u.FilterListsByPerm(PermTypeGet|PermTypeManage, listIDs)
|
||||
if len(listIDs) == 0 {
|
||||
if _, ok := u.PermissionsMap[PermSubscribersGetAll]; !ok {
|
||||
if len(u.GetListIDs) > 0 {
|
||||
return u.GetListIDs
|
||||
}
|
||||
return []int{-1}
|
||||
}
|
||||
}
|
||||
return listIDs
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package bounce
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"source.offmarket.win/aleagle/eaglecast/internal/bounce/mailbox"
|
||||
"source.offmarket.win/aleagle/eaglecast/internal/bounce/webhooks"
|
||||
"source.offmarket.win/aleagle/eaglecast/models"
|
||||
)
|
||||
|
||||
// Mailbox represents a POP/IMAP mailbox client that can scan messages and pass
|
||||
// them to a given channel.
|
||||
type Mailbox interface {
|
||||
Scan(limit int, ch chan models.Bounce) error
|
||||
}
|
||||
|
||||
// Opt represents bounce processing options.
|
||||
type Opt struct {
|
||||
MailboxEnabled bool `json:"mailbox_enabled"`
|
||||
MailboxType string `json:"mailbox_type"`
|
||||
Mailbox mailbox.Opt `json:"mailbox"`
|
||||
WebhooksEnabled bool `json:"webhooks_enabled"`
|
||||
SESEnabled bool `json:"ses_enabled"`
|
||||
AzureEnabled bool `json:"azure_enabled"`
|
||||
AzureSharedSecret string `json:"azure_shared_secret"`
|
||||
AzureSharedSecretHeader string `json:"azure_shared_secret_header"`
|
||||
SendgridEnabled bool `json:"sendgrid_enabled"`
|
||||
SendgridKey string `json:"sendgrid_key"`
|
||||
Postmark struct {
|
||||
Enabled bool
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
ForwardEmail struct {
|
||||
Enabled bool
|
||||
Key string
|
||||
}
|
||||
Lettermint struct {
|
||||
Enabled bool
|
||||
Key string
|
||||
}
|
||||
|
||||
RecordBounceCB func(models.Bounce) error
|
||||
}
|
||||
|
||||
// Manager handles e-mail bounces.
|
||||
type Manager struct {
|
||||
queue chan models.Bounce
|
||||
mailbox Mailbox
|
||||
SES *webhooks.SES
|
||||
Azure *webhooks.Azure
|
||||
Sendgrid *webhooks.Sendgrid
|
||||
Postmark *webhooks.Postmark
|
||||
Forwardemail *webhooks.Forwardemail
|
||||
Lettermint *webhooks.Lettermint
|
||||
queries *Queries
|
||||
opt Opt
|
||||
log *log.Logger
|
||||
}
|
||||
|
||||
// Queries contains the queries.
|
||||
type Queries struct {
|
||||
DB *sqlx.DB
|
||||
RecordQuery *sqlx.Stmt
|
||||
}
|
||||
|
||||
// New returns a new instance of the bounce manager.
|
||||
func New(opt Opt, q *Queries, lo *log.Logger) (*Manager, error) {
|
||||
m := &Manager{
|
||||
opt: opt,
|
||||
queries: q,
|
||||
queue: make(chan models.Bounce, 1000),
|
||||
log: lo,
|
||||
}
|
||||
|
||||
// Is there a mailbox?
|
||||
if opt.MailboxEnabled {
|
||||
switch opt.MailboxType {
|
||||
case "pop":
|
||||
m.mailbox = mailbox.NewPOP(opt.Mailbox, lo)
|
||||
default:
|
||||
return nil, errors.New("unknown bounce mailbox type")
|
||||
}
|
||||
}
|
||||
|
||||
if opt.WebhooksEnabled {
|
||||
if opt.SESEnabled {
|
||||
m.SES = webhooks.NewSES()
|
||||
}
|
||||
if opt.AzureEnabled {
|
||||
m.Azure = webhooks.NewAzure(opt.AzureSharedSecret, opt.AzureSharedSecretHeader)
|
||||
}
|
||||
|
||||
if opt.SendgridEnabled {
|
||||
sg, err := webhooks.NewSendgrid(opt.SendgridKey)
|
||||
if err != nil {
|
||||
lo.Printf("error initializing sendgrid webhooks: %v", err)
|
||||
} else {
|
||||
m.Sendgrid = sg
|
||||
}
|
||||
}
|
||||
|
||||
if opt.Postmark.Enabled {
|
||||
m.Postmark = webhooks.NewPostmark(opt.Postmark.Username, opt.Postmark.Password)
|
||||
}
|
||||
|
||||
if opt.ForwardEmail.Enabled {
|
||||
fe := webhooks.NewForwardemail([]byte(opt.ForwardEmail.Key))
|
||||
m.Forwardemail = fe
|
||||
}
|
||||
|
||||
if opt.Lettermint.Enabled {
|
||||
m.Lettermint = webhooks.NewLettermint([]byte(opt.Lettermint.Key))
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Run is a blocking function that listens for bounce events from webhooks and or mailboxes
|
||||
// and executes them on the DB.
|
||||
func (m *Manager) Run() {
|
||||
if m.opt.MailboxEnabled {
|
||||
go m.runMailboxScanner()
|
||||
}
|
||||
|
||||
for b := range m.queue {
|
||||
if b.CreatedAt.IsZero() {
|
||||
b.CreatedAt = time.Now()
|
||||
}
|
||||
|
||||
if err := m.opt.RecordBounceCB(b); err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runMailboxScanner runs a blocking loop that scans the mailbox at given intervals.
|
||||
func (m *Manager) runMailboxScanner() {
|
||||
for {
|
||||
m.log.Printf("scanning bounce mailbox %s", m.opt.Mailbox.Host)
|
||||
if err := m.mailbox.Scan(1000, m.queue); err != nil {
|
||||
m.log.Printf("error scanning bounce mailbox: %v", err)
|
||||
}
|
||||
|
||||
time.Sleep(m.opt.Mailbox.ScanInterval)
|
||||
}
|
||||
}
|
||||
|
||||
// Record records a new bounce event given the subscriber's email or UUID.
|
||||
func (m *Manager) Record(b models.Bounce) error {
|
||||
m.queue <- b
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package mailbox
|
||||
|
||||
import "time"
|
||||
|
||||
// Opt represents an e-mail POP/IMAP mailbox configuration.
|
||||
type Opt struct {
|
||||
// Host is the server's hostname.
|
||||
Host string `json:"host"`
|
||||
|
||||
// Port is the server port.
|
||||
Port int `json:"port"`
|
||||
|
||||
AuthProtocol string `json:"auth_protocol"`
|
||||
|
||||
// Username is the mail server login username.
|
||||
Username string `json:"username"`
|
||||
|
||||
// Password is the mail server login password.
|
||||
Password string `json:"password"`
|
||||
|
||||
// Folder is the name of the IMAP folder to scan for e-mails.
|
||||
Folder string `json:"folder"`
|
||||
|
||||
// Optional TLS settings.
|
||||
TLSEnabled bool `json:"tls_enabled"`
|
||||
TLSSkipVerify bool `json:"tls_skip_verify"`
|
||||
|
||||
ScanInterval time.Duration `json:"scan_interval"`
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package mailbox
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-message"
|
||||
_ "github.com/emersion/go-message/charset"
|
||||
"github.com/knadh/go-pop3"
|
||||
"source.offmarket.win/aleagle/eaglecast/models"
|
||||
)
|
||||
|
||||
// POP represents a POP mailbox.
|
||||
type POP struct {
|
||||
opt Opt
|
||||
client *pop3.Client
|
||||
lo *log.Logger
|
||||
}
|
||||
|
||||
type bounceHeaders struct {
|
||||
Header string
|
||||
Regexp *regexp.Regexp
|
||||
}
|
||||
|
||||
type bounceMeta struct {
|
||||
From string `json:"from"`
|
||||
Subject string `json:"subject"`
|
||||
MessageID string `json:"message_id"`
|
||||
DeliveredTo string `json:"delivered_to"`
|
||||
Received []string `json:"received"`
|
||||
ClassifyReason string `json:"classify_reason"`
|
||||
}
|
||||
|
||||
var (
|
||||
// List of header to look for in the e-mail body, regexp to fall back to if the header is empty.
|
||||
headerLookups = []bounceHeaders{
|
||||
{models.EmailHeaderCampaignUUID, regexp.MustCompile(`(?m)(?:^` + models.EmailHeaderCampaignUUID + `:\s+?)([a-z0-9\-]{36})`)},
|
||||
{models.EmailHeaderSubscriberUUID, regexp.MustCompile(`(?m)(?:^` + models.EmailHeaderSubscriberUUID + `:\s+?)([a-z0-9\-]{36})`)},
|
||||
{models.EmailHeaderDate, regexp.MustCompile(`(?m)(?:^` + models.EmailHeaderDate + `:\s+?)([\w,\,\ ,:,+,-]*(?:\(?:\w*\))?)`)},
|
||||
{models.EmailHeaderFrom, regexp.MustCompile(`(?m)(?:^` + models.EmailHeaderFrom + `:\s+?)(.*)`)},
|
||||
{models.EmailHeaderSubject, regexp.MustCompile(`(?m)(?:^` + models.EmailHeaderSubject + `:\s+?)(.*)`)},
|
||||
{models.EmailHeaderMessageId, regexp.MustCompile(`(?m)(?:^` + models.EmailHeaderMessageId + `:\s+?)(.*)`)},
|
||||
{models.EmailHeaderDeliveredTo, regexp.MustCompile(`(?m)(?:^` + models.EmailHeaderDeliveredTo + `:\s+?)(.*)`)},
|
||||
}
|
||||
|
||||
reHdrReceived = regexp.MustCompile(`(?m)(?:^` + models.EmailHeaderReceived + `:\s+?)(.*)`)
|
||||
|
||||
// SMTP status code (5.x.x or 4.x.x) to classify hard/soft bounces.
|
||||
reSMTPStatus = regexp.MustCompile(`(?m)(?i)^(?:Status:\s*)?(?:\d{3}\s+)?([45]\.\d+\.\d+)`)
|
||||
|
||||
// List of (conventional) strings to guess hard bounces.
|
||||
reHardBounce = regexp.MustCompile(`(?i)(NXDOMAIN|user unknown|address not found|mailbox not found|address.*reject|does not exist|` +
|
||||
`invalid recipient|no such user|recipient.*invalid|undeliverable|permanent.*failure|permanent.*error|` +
|
||||
`bad.*address|unknown.*user|account.*disabled|address.*disabled)`)
|
||||
)
|
||||
|
||||
// NewPOP returns a new instance of the POP mailbox client.
|
||||
func NewPOP(opt Opt, lo *log.Logger) *POP {
|
||||
return &POP{
|
||||
opt: opt,
|
||||
client: pop3.New(pop3.Opt{
|
||||
Host: opt.Host,
|
||||
Port: opt.Port,
|
||||
TLSEnabled: opt.TLSEnabled,
|
||||
TLSSkipVerify: opt.TLSSkipVerify,
|
||||
}),
|
||||
lo: lo,
|
||||
}
|
||||
}
|
||||
|
||||
// Scan scans the mailbox and pushes the downloaded messages into the given channel.
|
||||
// The messages that are downloaded are deleted from the server. If limit > 0,
|
||||
// all messages on the server are downloaded and deleted.
|
||||
func (p *POP) Scan(limit int, ch chan models.Bounce) error {
|
||||
c, err := p.client.NewConn()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer c.Quit()
|
||||
|
||||
// Authenticate.
|
||||
if p.opt.AuthProtocol != "none" {
|
||||
if err := c.Auth(p.opt.Username, p.opt.Password); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Get the total number of messages on the server.
|
||||
count, _, err := c.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// No messages.
|
||||
if count == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if limit > 0 && count > limit {
|
||||
count = limit
|
||||
}
|
||||
|
||||
// Download messages.
|
||||
for id := 1; id <= count; id++ {
|
||||
// Retrieve the raw bytes of the message.
|
||||
b, err := c.RetrRaw(id)
|
||||
if err != nil {
|
||||
p.lo.Printf("error retrieving bounce message %d: %v", id, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse the message.
|
||||
m, err := message.Read(b)
|
||||
if err != nil {
|
||||
p.lo.Printf("error parsing bounce message %d: %v", id, err)
|
||||
continue
|
||||
}
|
||||
|
||||
h := m
|
||||
|
||||
// If this is a multipart message, find the last part.
|
||||
if mr := m.MultipartReader(); mr != nil {
|
||||
for {
|
||||
part, err := mr.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
} else if err != nil {
|
||||
p.lo.Printf("error reading multipart bounce message %d: %v", id, err)
|
||||
continue
|
||||
}
|
||||
h = part
|
||||
}
|
||||
}
|
||||
|
||||
// Reset the "unread portion" pointer of the message buffer.
|
||||
// If you don't do this, you can't read the entire body because the pointer will not point to the beginning.
|
||||
b, _ = c.RetrRaw(id)
|
||||
|
||||
// Lookup headers in the e-mail. If a header isn't found, fall back to regexp lookups.
|
||||
hdr := make(map[string]string, 7)
|
||||
for _, l := range headerLookups {
|
||||
v := h.Header.Get(l.Header)
|
||||
|
||||
// Not in the header. Try regexp.
|
||||
if v == "" {
|
||||
if m := l.Regexp.FindAllSubmatch(b.Bytes(), -1); m != nil {
|
||||
v = string(m[len(m)-1][1])
|
||||
}
|
||||
}
|
||||
|
||||
hdr[l.Header] = strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
// Received is a []string header.
|
||||
msgReceived := h.Header.Map()[models.EmailHeaderReceived]
|
||||
if len(msgReceived) == 0 {
|
||||
if u := reHdrReceived.FindAllSubmatch(b.Bytes(), -1); u != nil {
|
||||
for i := range u {
|
||||
msgReceived = append(msgReceived, string(u[i][1]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
date, _ := time.Parse("Mon, 02 Jan 2006 15:04:05 -0700", hdr[models.EmailHeaderDate])
|
||||
if date.IsZero() {
|
||||
date = time.Now()
|
||||
}
|
||||
|
||||
// Classify the bounce type based on message content.
|
||||
bounceType, bounceReason := classifyBounce(b.Bytes())
|
||||
|
||||
// Additional bounce e-mail metadata.
|
||||
meta, _ := json.Marshal(bounceMeta{
|
||||
From: hdr[models.EmailHeaderFrom],
|
||||
Subject: hdr[models.EmailHeaderSubject],
|
||||
MessageID: hdr[models.EmailHeaderMessageId],
|
||||
DeliveredTo: hdr[models.EmailHeaderDeliveredTo],
|
||||
Received: msgReceived,
|
||||
ClassifyReason: bounceReason,
|
||||
})
|
||||
|
||||
select {
|
||||
case ch <- models.Bounce{
|
||||
Type: bounceType,
|
||||
CampaignUUID: hdr[models.EmailHeaderCampaignUUID],
|
||||
SubscriberUUID: hdr[models.EmailHeaderSubscriberUUID],
|
||||
Source: p.opt.Host,
|
||||
CreatedAt: date,
|
||||
Meta: meta,
|
||||
}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the downloaded messages.
|
||||
for id := 1; id <= count; id++ {
|
||||
if err := c.Dele(id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// classifyBounce analyzes the bounce message content and determines if it's a hard or soft bounce.
|
||||
// It checks SMTP status codes, diagnostic headers, and bounce keywords (using string heuristics).
|
||||
// soft is the default preference.
|
||||
// Returns the bounce type and a classification reason containing context about what matched.
|
||||
func classifyBounce(b []byte) (string, string) {
|
||||
if matches := reSMTPStatus.FindAllSubmatch(b, -1); matches != nil {
|
||||
for _, m := range matches {
|
||||
if len(m) >= 2 && len(m[0]) > 1 {
|
||||
// Full status code (e.g., "5.1.1").
|
||||
status := m[1]
|
||||
|
||||
// 5.x.x is hard bounce.
|
||||
if status[0] == '5' {
|
||||
return models.BounceTypeHard, fmt.Sprintf("smtp_status=%s", status)
|
||||
}
|
||||
|
||||
// 4.x.x is soft bounce.
|
||||
if status[0] == '4' {
|
||||
return models.BounceTypeSoft, fmt.Sprintf("smtp_status=%s", status)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for explicit hard bounce keywords.
|
||||
if match := reHardBounce.FindSubmatch(b); match != nil {
|
||||
return models.BounceTypeHard, fmt.Sprintf("body_match=%s", match[1])
|
||||
}
|
||||
|
||||
return models.BounceTypeSoft, "default"
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package webhooks
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"source.offmarket.win/aleagle/eaglecast/models"
|
||||
)
|
||||
|
||||
var reSMTPStatus = regexp.MustCompile(`\b([245]\.\d\.\d)\b`)
|
||||
|
||||
type azureEvent struct {
|
||||
EventType string `json:"eventType"`
|
||||
Data azureEventData `json:"data"`
|
||||
RawData json.RawMessage `json:"-"`
|
||||
}
|
||||
|
||||
type azureEventData struct {
|
||||
Recipient string `json:"recipient"`
|
||||
MessageID string `json:"messageId"`
|
||||
InternetMessageID string `json:"internetMessageId"`
|
||||
Status string `json:"status"`
|
||||
DeliveryStatusDetails azureDeliveryStatus `json:"deliveryStatusDetails"`
|
||||
DeliveryAttemptTimestamp *time.Time `json:"deliveryAttemptTimestamp"`
|
||||
DeliveryAttemptTimeStamp *time.Time `json:"deliveryAttemptTimeStamp"`
|
||||
AdditionalData map[string]json.RawMessage `json:"-"`
|
||||
}
|
||||
|
||||
type azureDeliveryStatus struct {
|
||||
StatusMessage string `json:"statusMessage"`
|
||||
}
|
||||
|
||||
// Azure handles webhook notifications from Azure Communication Services through Event Grid.
|
||||
type Azure struct {
|
||||
sharedSecret string
|
||||
sharedSecretHeader string
|
||||
}
|
||||
|
||||
// NewAzure returns a new Azure webhook handler.
|
||||
func NewAzure(sharedSecret, sharedSecretHeader string) *Azure {
|
||||
return &Azure{
|
||||
sharedSecret: strings.TrimSpace(sharedSecret),
|
||||
sharedSecretHeader: strings.TrimSpace(sharedSecretHeader),
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessSubscription processes Event Grid subscription validation requests and
|
||||
// returns the response payload that should be written to HTTP response body.
|
||||
func (a *Azure) ProcessSubscription(b []byte) (json.RawMessage, error) {
|
||||
events, err := parseAzureEvents(b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(events) == 0 {
|
||||
return nil, errors.New("empty event payload")
|
||||
}
|
||||
|
||||
// Validation code arrives in the first event for subscription validation flow.
|
||||
var payload map[string]json.RawMessage
|
||||
if err := json.Unmarshal(events[0].RawData, &payload); err != nil {
|
||||
return nil, fmt.Errorf("error reading validation payload: %v", err)
|
||||
}
|
||||
|
||||
rawData, ok := payload["data"]
|
||||
if !ok {
|
||||
return nil, errors.New("missing event data")
|
||||
}
|
||||
|
||||
var data map[string]string
|
||||
if err := json.Unmarshal(rawData, &data); err != nil {
|
||||
return nil, fmt.Errorf("error reading validation data: %v", err)
|
||||
}
|
||||
|
||||
code := strings.TrimSpace(data["validationCode"])
|
||||
if code == "" {
|
||||
return nil, errors.New("missing validationCode in subscription payload")
|
||||
}
|
||||
|
||||
res, _ := json.Marshal(map[string]string{
|
||||
"validationResponse": code,
|
||||
})
|
||||
return json.RawMessage(res), nil
|
||||
}
|
||||
|
||||
// ProcessBounce parses Azure Event Grid email delivery events and returns bounce entries.
|
||||
func (a *Azure) ProcessBounce(req *http.Request, b []byte) ([]models.Bounce, error) {
|
||||
if err := a.verifyAuth(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
events, err := parseAzureEvents(b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]models.Bounce, 0, len(events))
|
||||
for _, ev := range events {
|
||||
// Ignore non-delivery report events.
|
||||
if ev.EventType != "Microsoft.Communication.EmailDeliveryReportReceived" {
|
||||
continue
|
||||
}
|
||||
|
||||
typ, ok := mapAzureStatus(ev.Data.Status, ev.Data.DeliveryStatusDetails.StatusMessage)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
email := strings.ToLower(strings.TrimSpace(ev.Data.Recipient))
|
||||
if email == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
tstamp := ev.Data.DeliveryAttemptTimestamp
|
||||
if tstamp == nil {
|
||||
tstamp = ev.Data.DeliveryAttemptTimeStamp
|
||||
}
|
||||
|
||||
createdAt := time.Now()
|
||||
if tstamp != nil && !tstamp.IsZero() {
|
||||
createdAt = *tstamp
|
||||
}
|
||||
|
||||
out = append(out, models.Bounce{
|
||||
Email: email,
|
||||
Type: typ,
|
||||
Meta: json.RawMessage(ev.RawData),
|
||||
Source: "azure",
|
||||
CreatedAt: createdAt,
|
||||
})
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *Azure) verifyAuth(req *http.Request) error {
|
||||
const (
|
||||
defaultSecretHeader = "X-EagleCast-Webhook-Secret"
|
||||
querySecretParam = "code"
|
||||
)
|
||||
|
||||
// If no local credential is configured, allow webhook payloads.
|
||||
if a.sharedSecret == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if req == nil {
|
||||
return errors.New("missing azure event grid request context")
|
||||
}
|
||||
|
||||
querySecret := strings.TrimSpace(req.URL.Query().Get(querySecretParam))
|
||||
if secretsEqual(a.sharedSecret, querySecret) {
|
||||
return nil
|
||||
}
|
||||
|
||||
headerName := a.sharedSecretHeader
|
||||
if headerName == "" {
|
||||
headerName = defaultSecretHeader
|
||||
}
|
||||
headerSecret := strings.TrimSpace(req.Header.Get(headerName))
|
||||
if secretsEqual(a.sharedSecret, headerSecret) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return errors.New("invalid azure event grid shared secret")
|
||||
}
|
||||
|
||||
func secretsEqual(expected, given string) bool {
|
||||
if expected == "" || given == "" {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(expected), []byte(given)) == 1
|
||||
}
|
||||
|
||||
func parseAzureEvents(b []byte) ([]azureEvent, error) {
|
||||
var raws []json.RawMessage
|
||||
if err := json.Unmarshal(b, &raws); err != nil {
|
||||
return nil, fmt.Errorf("error unmarshalling azure notification array: %v", err)
|
||||
}
|
||||
|
||||
events := make([]azureEvent, 0, len(raws))
|
||||
for _, raw := range raws {
|
||||
ev := azureEvent{RawData: raw}
|
||||
if err := json.Unmarshal(raw, &ev); err != nil {
|
||||
return nil, fmt.Errorf("error unmarshalling azure event: %v", err)
|
||||
}
|
||||
events = append(events, ev)
|
||||
}
|
||||
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func mapAzureStatus(status, details string) (string, bool) {
|
||||
s := strings.ToLower(strings.TrimSpace(status))
|
||||
d := strings.ToLower(strings.TrimSpace(details))
|
||||
|
||||
switch s {
|
||||
case "bounced", "suppressed":
|
||||
return models.BounceTypeHard, true
|
||||
case "failed":
|
||||
// Infer severity from SMTP-enhanced status if available.
|
||||
if m := reSMTPStatus.FindStringSubmatch(d); len(m) > 1 {
|
||||
if strings.HasPrefix(m[1], "5.") {
|
||||
return models.BounceTypeHard, true
|
||||
}
|
||||
if strings.HasPrefix(m[1], "4.") {
|
||||
return models.BounceTypeSoft, true
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(d, "mailbox full") ||
|
||||
strings.Contains(d, "temporar") ||
|
||||
strings.Contains(d, "timeout") ||
|
||||
strings.Contains(d, "rate limit") ||
|
||||
strings.Contains(d, "throttle") {
|
||||
return models.BounceTypeSoft, true
|
||||
}
|
||||
if strings.Contains(d, "invalid") ||
|
||||
strings.Contains(d, "does not exist") ||
|
||||
strings.Contains(d, "unknown user") ||
|
||||
strings.Contains(d, "domain") {
|
||||
return models.BounceTypeHard, true
|
||||
}
|
||||
|
||||
return models.BounceTypeSoft, true
|
||||
case "filteredspam", "quarantined":
|
||||
if strings.Contains(d, "spam complaint") ||
|
||||
strings.Contains(d, "complaint") ||
|
||||
strings.Contains(d, "abuse") {
|
||||
return models.BounceTypeComplaint, true
|
||||
}
|
||||
return models.BounceTypeSoft, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package webhooks
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"source.offmarket.win/aleagle/eaglecast/models"
|
||||
)
|
||||
|
||||
type BounceDetails struct {
|
||||
Action string `json:"action"`
|
||||
Message string `json:"message"`
|
||||
Category string `json:"category"`
|
||||
Code int `json:"code"`
|
||||
Status any `json:"status"`
|
||||
Line int `json:"line"`
|
||||
}
|
||||
|
||||
type forwardemailNotif struct {
|
||||
EmailID string `json:"email_id"`
|
||||
ListID string `json:"list_id"`
|
||||
ListUnsubscribe string `json:"list_unsubscribe"`
|
||||
FeedbackID string `json:"feedback_id"`
|
||||
Recipient string `json:"recipient"`
|
||||
Message string `json:"message"`
|
||||
Response string `json:"response"`
|
||||
ResponseCode int `json:"response_code"`
|
||||
TruthSource string `json:"truth_source"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
Bounce BounceDetails `json:"bounce"`
|
||||
BouncedAt time.Time `json:"bounced_at"`
|
||||
}
|
||||
|
||||
// Forwardemail handles webhook notifications (mainly bounce notifications).
|
||||
type Forwardemail struct {
|
||||
hmacKey []byte
|
||||
}
|
||||
|
||||
func NewForwardemail(key []byte) *Forwardemail {
|
||||
return &Forwardemail{hmacKey: key}
|
||||
}
|
||||
|
||||
func (p *Forwardemail) ProcessBounce(sigHex string, body []byte) ([]models.Bounce, error) {
|
||||
if len(p.hmacKey) == 0 {
|
||||
return nil, errors.New("webhook key is not configured")
|
||||
}
|
||||
|
||||
// Decode the hex-encoded signature from the webhook
|
||||
sig, err := hex.DecodeString(sigHex)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid signature encoding: %v", err)
|
||||
}
|
||||
|
||||
// Generate HMAC using the request body and secret key
|
||||
mac := hmac.New(sha256.New, p.hmacKey)
|
||||
mac.Write(body)
|
||||
expectedSignature := mac.Sum(nil)
|
||||
|
||||
// Compare the generated signature with the provided signature
|
||||
if !hmac.Equal(expectedSignature, sig) {
|
||||
return nil, errors.New("invalid signature")
|
||||
}
|
||||
|
||||
// Parse the JSON payload
|
||||
var n forwardemailNotif
|
||||
if err := json.Unmarshal(body, &n); err != nil {
|
||||
return nil, fmt.Errorf("error unmarshalling Forwardemail notification: %v", err)
|
||||
}
|
||||
|
||||
// Categorize the bounce type
|
||||
typ := models.BounceTypeSoft
|
||||
hardBounceCategories := []string{"block", "recipient", "virus", "spam"}
|
||||
for _, category := range hardBounceCategories {
|
||||
if n.Bounce.Category == category {
|
||||
typ = models.BounceTypeHard
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
campUUID := ""
|
||||
if v, ok := n.Headers["X-EagleCast-Campaign"]; ok {
|
||||
campUUID = v
|
||||
}
|
||||
|
||||
return []models.Bounce{{
|
||||
Email: strings.ToLower(n.Recipient),
|
||||
CampaignUUID: campUUID,
|
||||
Type: typ,
|
||||
Source: "forwardemail",
|
||||
Meta: json.RawMessage(body),
|
||||
CreatedAt: n.BouncedAt,
|
||||
}}, nil
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package webhooks
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"source.offmarket.win/aleagle/eaglecast/models"
|
||||
)
|
||||
|
||||
type lettermintNotif struct {
|
||||
ID string `json:"id"`
|
||||
Event string `json:"event"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Data struct {
|
||||
MessageID string `json:"message_id"`
|
||||
Subject string `json:"subject"`
|
||||
Recipient string `json:"recipient"`
|
||||
Response *struct {
|
||||
StatusCode int `json:"status_code"`
|
||||
EnhancedStatus string `json:"enhanced_status_code"`
|
||||
Content string `json:"content"`
|
||||
} `json:"response"`
|
||||
Metadata json.RawMessage `json:"metadata"`
|
||||
Tag string `json:"tag"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// Lettermint handles bounce webhook notifications from Lettermint.
|
||||
type Lettermint struct {
|
||||
hmacKey []byte
|
||||
}
|
||||
|
||||
// NewLettermint returns a new Lettermint webhook handler.
|
||||
func NewLettermint(key []byte) *Lettermint {
|
||||
return &Lettermint{hmacKey: key}
|
||||
}
|
||||
|
||||
// ProcessBounce processes an incoming Lettermint webhook payload and returns a bounce object.
|
||||
func (l *Lettermint) ProcessBounce(sig string, body []byte) ([]models.Bounce, error) {
|
||||
if len(l.hmacKey) == 0 {
|
||||
return nil, fmt.Errorf("webhook key is not configured")
|
||||
}
|
||||
|
||||
// Parse the signature header: t={timestamp},v1={hex_signature}.
|
||||
ts, sigHex, err := parseLettermintSignature(sig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Verify timestamp tolerance (300 seconds).
|
||||
if math.Abs(float64(time.Now().Unix()-ts)) > 300 {
|
||||
return nil, fmt.Errorf("signature timestamp expired")
|
||||
}
|
||||
|
||||
// Decode the hex signature from the header.
|
||||
sigB, err := hex.DecodeString(strings.TrimSpace(sigHex))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid signature encoding: %v", err)
|
||||
}
|
||||
|
||||
// Compute HMAC-SHA256 of "{timestamp}.{body}" and compare.
|
||||
mac := hmac.New(sha256.New, l.hmacKey)
|
||||
mac.Write([]byte(fmt.Sprintf("%d.%s", ts, body)))
|
||||
|
||||
if !hmac.Equal(mac.Sum(nil), sigB) {
|
||||
return nil, fmt.Errorf("invalid signature")
|
||||
}
|
||||
|
||||
var n lettermintNotif
|
||||
if err := json.Unmarshal(body, &n); err != nil {
|
||||
return nil, fmt.Errorf("error unmarshalling Lettermint notification: %v", err)
|
||||
}
|
||||
|
||||
// Map event to bounce type.
|
||||
var typ string
|
||||
switch n.Event {
|
||||
case "message.hard_bounced":
|
||||
typ = models.BounceTypeHard
|
||||
case "message.soft_bounced":
|
||||
typ = models.BounceTypeSoft
|
||||
case "message.spam_complaint":
|
||||
typ = models.BounceTypeComplaint
|
||||
default:
|
||||
// Ignore irrelevant events (e.g. webhook.test).
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
campUUID := ""
|
||||
if len(n.Data.Metadata) > 0 {
|
||||
var meta map[string]string
|
||||
if err := json.Unmarshal(n.Data.Metadata, &meta); err == nil {
|
||||
if v, ok := meta["X-EagleCast-Campaign"]; ok {
|
||||
campUUID = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
t, _ := time.Parse(time.RFC3339, n.Timestamp)
|
||||
if t.IsZero() {
|
||||
t = time.Now()
|
||||
}
|
||||
|
||||
return []models.Bounce{{
|
||||
Email: strings.ToLower(n.Data.Recipient),
|
||||
CampaignUUID: campUUID,
|
||||
Type: typ,
|
||||
Source: "lettermint",
|
||||
Meta: json.RawMessage(body),
|
||||
CreatedAt: t,
|
||||
}}, nil
|
||||
}
|
||||
|
||||
// parseLettermintSignature parses a signature header of the form "t={timestamp},v1={hex}".
|
||||
func parseLettermintSignature(sig string) (int64, string, error) {
|
||||
var (
|
||||
ts int64
|
||||
hash string
|
||||
)
|
||||
|
||||
for _, part := range strings.Split(sig, ",") {
|
||||
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
|
||||
if len(kv) != 2 {
|
||||
continue
|
||||
}
|
||||
switch kv[0] {
|
||||
case "t":
|
||||
if _, err := fmt.Sscanf(kv[1], "%d", &ts); err != nil {
|
||||
return 0, "", fmt.Errorf("invalid timestamp in signature: %v", err)
|
||||
}
|
||||
case "v1":
|
||||
hash = kv[1]
|
||||
}
|
||||
}
|
||||
|
||||
if ts == 0 || hash == "" {
|
||||
return 0, "", fmt.Errorf("invalid signature format")
|
||||
}
|
||||
|
||||
return ts, hash, nil
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package webhooks
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"source.offmarket.win/aleagle/eaglecast/models"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
)
|
||||
|
||||
type postmarkNotif struct {
|
||||
RecordType string `json:"RecordType"`
|
||||
MessageStream string `json:"MessageStream"`
|
||||
ID int `json:"ID"`
|
||||
Type string `json:"Type"`
|
||||
TypeCode int `json:"TypeCode"`
|
||||
Name string `json:"Name"`
|
||||
Tag string `json:"Tag"`
|
||||
MessageID string `json:"MessageID"`
|
||||
Metadata map[string]string `json:"Metadata"`
|
||||
ServerID int `json:"ServerID"`
|
||||
Description string `json:"Description"`
|
||||
Details string `json:"Details"`
|
||||
Email string `json:"Email"`
|
||||
From string `json:"From"`
|
||||
BouncedAt time.Time `json:"BouncedAt"` // "2019-11-05T16:33:54.9070259Z"
|
||||
DumpAvailable bool `json:"DumpAvailable"`
|
||||
Inactive bool `json:"Inactive"`
|
||||
CanActivate bool `json:"CanActivate"`
|
||||
Subject string `json:"Subject"`
|
||||
Content string `json:"Content"`
|
||||
}
|
||||
|
||||
// Postmark handles webhook notifications (mainly bounce notifications).
|
||||
type Postmark struct {
|
||||
authHandler echo.HandlerFunc
|
||||
}
|
||||
|
||||
func NewPostmark(username, password string) *Postmark {
|
||||
return &Postmark{
|
||||
authHandler: middleware.BasicAuth(makePostmarkAuthHandler(username, password))(func(c echo.Context) error {
|
||||
return nil
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessBounce processes Postmark bounce notifications and returns one object.
|
||||
func (p *Postmark) ProcessBounce(b []byte, c echo.Context) ([]models.Bounce, error) {
|
||||
// Do basicauth.
|
||||
if err := p.authHandler(c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var n postmarkNotif
|
||||
if err := json.Unmarshal(b, &n); err != nil {
|
||||
return nil, fmt.Errorf("error unmarshalling postmark notification: %v", err)
|
||||
}
|
||||
|
||||
// Ignore irrelevant messages.
|
||||
if n.RecordType != "Bounce" && n.RecordType != "SpamComplaint" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
supportedBounceType := true
|
||||
typ := models.BounceTypeHard
|
||||
switch n.Type {
|
||||
case "HardBounce", "BadEmailAddress", "ManuallyDeactivated":
|
||||
typ = models.BounceTypeHard
|
||||
case "SoftBounce", "Transient", "DnsError", "SpamNotification", "VirusNotification", "DMARCPolicy":
|
||||
typ = models.BounceTypeSoft
|
||||
case "SpamComplaint":
|
||||
typ = models.BounceTypeComplaint
|
||||
default:
|
||||
supportedBounceType = false
|
||||
}
|
||||
|
||||
if !supportedBounceType {
|
||||
return nil, fmt.Errorf("unsupported bounce type: %v", n.Type)
|
||||
}
|
||||
|
||||
// Look for the campaign ID in headers.
|
||||
campUUID := ""
|
||||
if v, ok := n.Metadata["X-EagleCast-Campaign"]; ok {
|
||||
campUUID = v
|
||||
}
|
||||
|
||||
return []models.Bounce{{
|
||||
Email: strings.ToLower(n.Email),
|
||||
CampaignUUID: campUUID,
|
||||
Type: typ,
|
||||
Source: "postmark",
|
||||
Meta: json.RawMessage(b),
|
||||
CreatedAt: n.BouncedAt,
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func makePostmarkAuthHandler(cfgUser, cfgPassword string) func(username, password string, c echo.Context) (bool, error) {
|
||||
var (
|
||||
u = []byte(cfgUser)
|
||||
p = []byte(cfgPassword)
|
||||
)
|
||||
|
||||
return func(username, password string, c echo.Context) (bool, error) {
|
||||
if len(u) == 0 || len(p) == 0 {
|
||||
return false, errors.New("webhook credentials are not configured")
|
||||
}
|
||||
|
||||
if subtle.ConstantTimeCompare([]byte(username), u) == 1 && subtle.ConstantTimeCompare([]byte(password), p) == 1 {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package webhooks
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/asn1"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"source.offmarket.win/aleagle/eaglecast/models"
|
||||
)
|
||||
|
||||
type sendgridNotif struct {
|
||||
Email string `json:"email"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Event string `json:"event"`
|
||||
BounceClassification string `json:"bounce_classification"`
|
||||
|
||||
// SendGrid flattens all X-headers and adds them to the bounce
|
||||
// event notification.
|
||||
CampaignUUID string `json:"XEagleCastCampaign"`
|
||||
}
|
||||
|
||||
// Sendgrid handles Sendgrid/SNS webhook notifications including confirming SNS topic subscription
|
||||
// requests and bounce notifications.
|
||||
type Sendgrid struct {
|
||||
pubKey *ecdsa.PublicKey
|
||||
}
|
||||
|
||||
// NewSendgrid returns a new Sendgrid instance.
|
||||
func NewSendgrid(key string) (*Sendgrid, error) {
|
||||
// Get the certificate from the key.
|
||||
sigB, err := base64.StdEncoding.DecodeString(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pubKey, err := x509.ParsePKIXPublicKey(sigB)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Sendgrid{pubKey: pubKey.(*ecdsa.PublicKey)}, nil
|
||||
}
|
||||
|
||||
// ProcessBounce processes Sendgrid bounce notifications and returns one or more Bounce objects.
|
||||
func (s *Sendgrid) ProcessBounce(sig, timestamp string, b []byte) ([]models.Bounce, error) {
|
||||
if err := s.verifyNotif(sig, timestamp, b); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var notifs []sendgridNotif
|
||||
if err := json.Unmarshal(b, ¬ifs); err != nil {
|
||||
return nil, fmt.Errorf("error unmarshalling Sendgrid notification: %v", err)
|
||||
}
|
||||
|
||||
out := make([]models.Bounce, 0, len(notifs))
|
||||
for _, n := range notifs {
|
||||
if n.Event != "bounce" {
|
||||
continue
|
||||
}
|
||||
|
||||
typ := models.BounceTypeHard
|
||||
if n.BounceClassification == "technical" || n.BounceClassification == "content" {
|
||||
typ = models.BounceTypeSoft
|
||||
}
|
||||
|
||||
tstamp := time.Unix(n.Timestamp, 0)
|
||||
bn := models.Bounce{
|
||||
CampaignUUID: n.CampaignUUID,
|
||||
Email: strings.ToLower(n.Email),
|
||||
Type: typ,
|
||||
Meta: json.RawMessage(b),
|
||||
Source: "sendgrid",
|
||||
CreatedAt: tstamp,
|
||||
}
|
||||
out = append(out, bn)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// verifyNotif verifies the signature on a notification payload.
|
||||
func (s *Sendgrid) verifyNotif(sig, timestamp string, b []byte) error {
|
||||
sigB, err := base64.StdEncoding.DecodeString(sig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ecdsaSig := struct {
|
||||
R *big.Int
|
||||
S *big.Int
|
||||
}{}
|
||||
|
||||
if _, err := asn1.Unmarshal(sigB, &ecdsaSig); err != nil {
|
||||
return fmt.Errorf("error asn1 unmarshal of signature: %v", err)
|
||||
}
|
||||
|
||||
h := sha256.New()
|
||||
h.Write([]byte(timestamp))
|
||||
h.Write(b)
|
||||
hash := h.Sum(nil)
|
||||
|
||||
if !ecdsa.Verify(s.pubKey, hash, ecdsaSig.R, ecdsaSig.S) {
|
||||
return errors.New("invalid signature")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package webhooks
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"source.offmarket.win/aleagle/eaglecast/models"
|
||||
)
|
||||
|
||||
// AWS signature/validation logic borrowed from @cavnit's contrib:
|
||||
// https://gist.github.com/cavnit/f4d63ba52b3aa05406c07dcbca2ca6cf
|
||||
|
||||
// https://sns.ap-southeast-1.amazonaws.com/SimpleNotificationService-010a507c1833636cd94bdb98bd93083a.pem
|
||||
var sesRegCertURL = regexp.MustCompile(`(?i)^https://sns\.[a-z0-9\-]+\.amazonaws\.com(\.cn)?/SimpleNotificationService\-[a-z0-9]+\.pem$`)
|
||||
|
||||
// sesNotif is an individual notification wrapper posted by SNS.
|
||||
type sesNotif struct {
|
||||
// Message may be a plaintext message or a stringified JSON payload based on the message type.
|
||||
// Four SES messages, this is the actual payload.
|
||||
Message string `json:"Message"`
|
||||
|
||||
MessageId string `json:"MessageId"`
|
||||
Signature string `json:"Signature"`
|
||||
SignatureVersion string `json:"SignatureVersion"`
|
||||
SigningCertURL string `json:"SigningCertURL"`
|
||||
Subject string `json:"Subject"`
|
||||
Timestamp string `json:"Timestamp"`
|
||||
Token string `json:"Token"`
|
||||
TopicArn string `json:"TopicArn"`
|
||||
Type string `json:"Type"`
|
||||
SubscribeURL string `json:"SubscribeURL"`
|
||||
UnsubscribeURL string `json:"UnsubscribeURL"`
|
||||
}
|
||||
|
||||
type sesTimestamp time.Time
|
||||
|
||||
type sesMail struct {
|
||||
EventType string `json:"eventType"`
|
||||
NotifType string `json:"notificationType"`
|
||||
Bounce struct {
|
||||
BounceType string `json:"bounceType"`
|
||||
BouncedRecipients []struct {
|
||||
Status string `json:"status"`
|
||||
} `json:"bouncedRecipients"`
|
||||
} `json:"bounce"`
|
||||
Mail struct {
|
||||
Timestamp sesTimestamp `json:"timestamp"`
|
||||
HeadersTruncated bool `json:"headersTruncated"`
|
||||
Destination []string `json:"destination"`
|
||||
Headers []map[string]string `json:"headers"`
|
||||
} `json:"mail"`
|
||||
}
|
||||
|
||||
// SES handles SES/SNS webhook notifications including confirming SNS topic subscription
|
||||
// requests and bounce notifications.
|
||||
type SES struct {
|
||||
mu sync.RWMutex
|
||||
certs map[string]*x509.Certificate
|
||||
}
|
||||
|
||||
// NewSES returns a new SES instance.
|
||||
func NewSES() *SES {
|
||||
return &SES{
|
||||
certs: make(map[string]*x509.Certificate),
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessSubscription processes an SNS topic subscribe / unsubscribe notification
|
||||
// by parsing and verifying the payload and calling the subscribe / unsubscribe URL.
|
||||
func (s *SES) ProcessSubscription(b []byte) error {
|
||||
var n sesNotif
|
||||
if err := json.Unmarshal(b, &n); err != nil {
|
||||
return fmt.Errorf("error unmarshalling SNS notification: %v", err)
|
||||
}
|
||||
if err := s.verifyNotif(n); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Make an HTTP request to the sub/unsub URL.
|
||||
u := n.SubscribeURL
|
||||
if n.Type == "UnsubscriptionConfirmation" {
|
||||
u = n.UnsubscribeURL
|
||||
}
|
||||
|
||||
resp, err := http.Get(u)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error requesting subscription URL: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("non 200 response on subscription URL: %v", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProcessBounce processes an SES bounce notification and returns a Bounce object.
|
||||
func (s *SES) ProcessBounce(b []byte) (models.Bounce, error) {
|
||||
var (
|
||||
bounce models.Bounce
|
||||
n sesNotif
|
||||
)
|
||||
if err := json.Unmarshal(b, &n); err != nil {
|
||||
return bounce, fmt.Errorf("error unmarshalling SES notification: %v", err)
|
||||
}
|
||||
if err := s.verifyNotif(n); err != nil {
|
||||
return bounce, err
|
||||
}
|
||||
|
||||
var m sesMail
|
||||
if err := json.Unmarshal([]byte(n.Message), &m); err != nil {
|
||||
return bounce, fmt.Errorf("error unmarshalling SES notification: %v", err)
|
||||
}
|
||||
|
||||
if (m.EventType != "" && m.EventType != "Bounce") ||
|
||||
(m.NotifType != "" && (m.NotifType != "Bounce" && m.NotifType != "Complaint")) {
|
||||
return bounce, errors.New("notification type is not bounce")
|
||||
}
|
||||
|
||||
if len(m.Mail.Destination) == 0 {
|
||||
return bounce, errors.New("no destination e-mails found in SES notification")
|
||||
}
|
||||
|
||||
typ := models.BounceTypeSoft
|
||||
if m.Bounce.BounceType == "Permanent" {
|
||||
typ = models.BounceTypeHard
|
||||
}
|
||||
if m.Bounce.BounceType == "Transient" && len(m.Bounce.BouncedRecipients) > 0 {
|
||||
// "Invalid domain" bounce.
|
||||
if m.Bounce.BouncedRecipients[0].Status == "5.4.4" {
|
||||
typ = models.BounceTypeHard
|
||||
}
|
||||
}
|
||||
if m.NotifType == "Complaint" {
|
||||
typ = models.BounceTypeComplaint
|
||||
}
|
||||
|
||||
// Look for the campaign ID in headers.
|
||||
campUUID := ""
|
||||
if !m.Mail.HeadersTruncated {
|
||||
for _, h := range m.Mail.Headers {
|
||||
key, ok := h["name"]
|
||||
if !ok || key != models.EmailHeaderCampaignUUID {
|
||||
continue
|
||||
}
|
||||
|
||||
campUUID, ok = h["value"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return models.Bounce{
|
||||
Email: strings.ToLower(m.Mail.Destination[0]),
|
||||
CampaignUUID: campUUID,
|
||||
Type: typ,
|
||||
Source: "ses",
|
||||
Meta: json.RawMessage(n.Message),
|
||||
CreatedAt: time.Time(m.Mail.Timestamp),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *SES) buildSignature(n sesNotif) []byte {
|
||||
var b bytes.Buffer
|
||||
b.WriteString("Message" + "\n" + n.Message + "\n")
|
||||
b.WriteString("MessageId" + "\n" + n.MessageId + "\n")
|
||||
|
||||
if n.Subject != "" {
|
||||
b.WriteString("Subject" + "\n" + n.Subject + "\n")
|
||||
}
|
||||
if n.SubscribeURL != "" {
|
||||
b.WriteString("SubscribeURL" + "\n" + n.SubscribeURL + "\n")
|
||||
}
|
||||
|
||||
b.WriteString("Timestamp" + "\n" + n.Timestamp + "\n")
|
||||
|
||||
if n.Token != "" {
|
||||
b.WriteString("Token" + "\n" + n.Token + "\n")
|
||||
}
|
||||
b.WriteString("TopicArn" + "\n" + n.TopicArn + "\n")
|
||||
b.WriteString("Type" + "\n" + n.Type + "\n")
|
||||
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
// verifyNotif verifies the signature on a notification payload.
|
||||
func (s *SES) verifyNotif(n sesNotif) error {
|
||||
// Get the message signing certificate.
|
||||
cert, err := s.getCert(n.SigningCertURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting SNS cert: %v", err)
|
||||
}
|
||||
|
||||
sign, err := base64.StdEncoding.DecodeString(n.Signature)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return cert.CheckSignature(x509.SHA1WithRSA, s.buildSignature(n), sign)
|
||||
}
|
||||
|
||||
// getCert takes the SNS certificate URL and fetches it and caches it for the first time,
|
||||
// and returns the cached cert for subsequent calls.
|
||||
func (s *SES) getCert(certURL string) (*x509.Certificate, error) {
|
||||
// Ensure that the cert URL is Amazon's.
|
||||
u, err := url.Parse(certURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !sesRegCertURL.MatchString(certURL) {
|
||||
return nil, fmt.Errorf("invalid SNS certificate URL: %v", u.Host)
|
||||
}
|
||||
|
||||
// Return if it's cached.
|
||||
s.mu.RLock()
|
||||
c, ok := s.certs[u.Path]
|
||||
s.mu.RUnlock()
|
||||
if ok {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Fetch the certificate.
|
||||
resp, err := http.Get(certURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("invalid SNS certificate URL: %v", u.Host)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p, _ := pem.Decode(body)
|
||||
if p == nil {
|
||||
return nil, errors.New("invalid PEM")
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(p.Bytes)
|
||||
|
||||
// Cache the cert in-memory.
|
||||
s.mu.Lock()
|
||||
// Check again if another goroutine already cached it while we were fetching.
|
||||
if c2, ok := s.certs[u.Path]; ok && c2 != nil {
|
||||
s.mu.Unlock()
|
||||
// Return the cached cert and ignore this goroutine's parse error (if any).
|
||||
return c2, nil
|
||||
}
|
||||
// Only cache when parsing succeeded (don't cache nil certs from failures).
|
||||
if err == nil {
|
||||
s.certs[u.Path] = cert
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
return cert, err
|
||||
}
|
||||
|
||||
func (st *sesTimestamp) UnmarshalJSON(b []byte) error {
|
||||
t, err := time.Parse("2006-01-02T15:04:05.999999999Z", strings.Trim(string(b), `"`))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*st = sesTimestamp(t)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package buflog
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// BufLog implements a simple log buffer that can be supplied to a std
|
||||
// log instance. It stores logs up to N lines.
|
||||
type BufLog struct {
|
||||
maxLines int
|
||||
buf *bytes.Buffer
|
||||
lines []string
|
||||
|
||||
sync.RWMutex
|
||||
}
|
||||
|
||||
// New returns a new log buffer that stores up to maxLines lines.
|
||||
func New(maxLines int) *BufLog {
|
||||
return &BufLog{
|
||||
maxLines: maxLines,
|
||||
buf: &bytes.Buffer{},
|
||||
lines: make([]string, 0, maxLines),
|
||||
}
|
||||
}
|
||||
|
||||
// Write writes a log item to the buffer maintaining maxLines capacity
|
||||
// using LIFO.
|
||||
func (bu *BufLog) Write(b []byte) (n int, err error) {
|
||||
bu.Lock()
|
||||
defer bu.Unlock()
|
||||
|
||||
if len(bu.lines) >= bu.maxLines {
|
||||
bu.lines[0] = ""
|
||||
bu.lines = bu.lines[1:len(bu.lines)]
|
||||
}
|
||||
bu.lines = append(bu.lines, strings.TrimSpace(string(b)))
|
||||
|
||||
return len(b), nil
|
||||
}
|
||||
|
||||
// Lines returns the log lines.
|
||||
func (bu *BufLog) Lines() []string {
|
||||
bu.RLock()
|
||||
defer bu.RUnlock()
|
||||
|
||||
out := make([]string, len(bu.lines))
|
||||
copy(out[:], bu.lines[:])
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package captcha
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/altcha-org/altcha-lib-go"
|
||||
"source.offmarket.win/aleagle/eaglecast/internal/tmptokens"
|
||||
)
|
||||
|
||||
const (
|
||||
hCaptchaURL = "https://hcaptcha.com/siteverify"
|
||||
)
|
||||
|
||||
type hCaptchaResp struct {
|
||||
Success bool `json:"success"`
|
||||
ErrorCodes []string `json:"error_codes"`
|
||||
}
|
||||
|
||||
const (
|
||||
ProviderNone = ""
|
||||
ProviderHCaptcha = "hcaptcha"
|
||||
ProviderAltcha = "altcha"
|
||||
)
|
||||
|
||||
// Captcha is a captcha client supporting multiple providers.
|
||||
type Captcha struct {
|
||||
provider string
|
||||
hCaptcha hCaptchaOpt
|
||||
altcha altchaOpt
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
type Opt struct {
|
||||
HCaptcha struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Key string `json:"key"`
|
||||
Secret string `json:"secret"`
|
||||
} `json:"hcaptcha"`
|
||||
Altcha struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Complexity int `json:"complexity"`
|
||||
} `json:"altcha"`
|
||||
}
|
||||
|
||||
type hCaptchaOpt struct {
|
||||
Secret string
|
||||
}
|
||||
|
||||
type altchaOpt struct {
|
||||
Complexity int
|
||||
HMACKey string
|
||||
}
|
||||
|
||||
// New returns a new instance of the CAPTCHA client.
|
||||
func New(o Opt) *Captcha {
|
||||
timeout := time.Second * 5
|
||||
|
||||
c := &Captcha{
|
||||
client: &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConnsPerHost: 10,
|
||||
MaxConnsPerHost: 100,
|
||||
ResponseHeaderTimeout: timeout,
|
||||
IdleConnTimeout: timeout,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Determine which provider is enabled
|
||||
if o.Altcha.Enabled {
|
||||
c.provider = ProviderAltcha
|
||||
|
||||
// Generate an random HMAC key for Altcha.
|
||||
b := make([]byte, 24) // 24 bytes will give 32 characters when base64 encoded
|
||||
_, err := rand.Read(b)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("error generating Altcha HMAC key: %v", err))
|
||||
}
|
||||
hmacKey := base64.URLEncoding.EncodeToString(b)[:32]
|
||||
|
||||
c.altcha = altchaOpt{
|
||||
Complexity: o.Altcha.Complexity,
|
||||
HMACKey: hmacKey,
|
||||
}
|
||||
} else if o.HCaptcha.Enabled {
|
||||
c.provider = ProviderHCaptcha
|
||||
c.hCaptcha = hCaptchaOpt{
|
||||
Secret: o.HCaptcha.Secret,
|
||||
}
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
// IsEnabled returns true if any captcha provider is enabled.
|
||||
func (c *Captcha) IsEnabled() bool {
|
||||
return c.provider != ProviderNone
|
||||
}
|
||||
|
||||
// GetProvider returns the active captcha provider.
|
||||
func (c *Captcha) GetProvider() string {
|
||||
return c.provider
|
||||
}
|
||||
|
||||
// GenerateChallenge generates a challenge for the active provider.
|
||||
// For hCaptcha, this returns empty string as challenges are generated client-side.
|
||||
// For Altcha, this returns a JSON challenge.
|
||||
func (c *Captcha) GenerateChallenge() (string, error) {
|
||||
switch c.provider {
|
||||
case ProviderAltcha:
|
||||
exp := time.Now().Add(5 * time.Minute)
|
||||
challenge, err := altcha.CreateChallenge(altcha.ChallengeOptions{
|
||||
Algorithm: altcha.SHA256,
|
||||
MaxNumber: int64(c.altcha.Complexity),
|
||||
SaltLength: 12,
|
||||
HMACKey: c.altcha.HMACKey,
|
||||
Expires: &exp,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create Altcha challenge: %w", err)
|
||||
}
|
||||
|
||||
challengeJSON, err := json.Marshal(challenge)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal Altcha challenge: %w", err)
|
||||
}
|
||||
|
||||
return string(challengeJSON), nil
|
||||
case ProviderHCaptcha:
|
||||
// hCaptcha generates challenges client-side.
|
||||
return "", nil
|
||||
default:
|
||||
return "", fmt.Errorf("no captcha provider enabled")
|
||||
}
|
||||
}
|
||||
|
||||
// Verify verifies a CAPTCHA response.
|
||||
func (c *Captcha) Verify(token string) (error, bool) {
|
||||
switch c.provider {
|
||||
case ProviderAltcha:
|
||||
return c.verifyAltcha(token)
|
||||
case ProviderHCaptcha:
|
||||
return c.verifyHCaptcha(token)
|
||||
default:
|
||||
return fmt.Errorf("no captcha provider enabled"), false
|
||||
}
|
||||
}
|
||||
|
||||
// verifyHCaptcha verifies an hCaptcha response.
|
||||
func (c *Captcha) verifyHCaptcha(token string) (error, bool) {
|
||||
resp, err := c.client.PostForm(hCaptchaURL, url.Values{
|
||||
"secret": {c.hCaptcha.Secret},
|
||||
"response": {token},
|
||||
})
|
||||
if err != nil {
|
||||
return err, false
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err, false
|
||||
}
|
||||
|
||||
var r hCaptchaResp
|
||||
if err := json.Unmarshal(body, &r); err != nil {
|
||||
return err, true
|
||||
}
|
||||
|
||||
if !r.Success {
|
||||
return fmt.Errorf("hCaptcha failed: %s", strings.Join(r.ErrorCodes, ",")), false
|
||||
}
|
||||
|
||||
return nil, true
|
||||
}
|
||||
|
||||
// verifyAltcha verifies an Altcha response.
|
||||
func (c *Captcha) verifyAltcha(payload string) (error, bool) {
|
||||
valid, err := altcha.VerifySolution(payload, c.altcha.HMACKey, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to verify captcha solution: %w", err), false
|
||||
}
|
||||
|
||||
if !valid {
|
||||
return fmt.Errorf("captcha verification failed"), false
|
||||
}
|
||||
|
||||
// Disallow token reuse.
|
||||
if _, err := tmptokens.Check(payload); err == nil {
|
||||
return fmt.Errorf("captcha token already used"), false
|
||||
}
|
||||
tmptokens.Set(payload, 5*time.Minute, nil)
|
||||
|
||||
return nil, true
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"source.offmarket.win/aleagle/eaglecast/models"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
var bounceQuerySortFields = []string{"email", "campaign_name", "source", "created_at", "type"}
|
||||
|
||||
// QueryBounces retrieves paginated bounce entries based on the given params.
|
||||
// It also returns the total number of bounce records in the DB.
|
||||
func (c *Core) QueryBounces(campID, subID int, source, orderBy, order string, offset, limit int) ([]models.Bounce, int, error) {
|
||||
if !strSliceContains(orderBy, bounceQuerySortFields) {
|
||||
orderBy = "created_at"
|
||||
}
|
||||
if order != SortAsc && order != SortDesc {
|
||||
order = SortDesc
|
||||
}
|
||||
|
||||
out := []models.Bounce{}
|
||||
stmt := strings.ReplaceAll(c.q.QueryBounces, "%order%", orderBy+" "+order)
|
||||
if err := c.db.Select(&out, stmt, 0, campID, subID, source, offset, limit); err != nil {
|
||||
c.log.Printf("error fetching bounces: %v", err)
|
||||
return nil, 0, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.bounce}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
total := 0
|
||||
if len(out) > 0 {
|
||||
total = out[0].Total
|
||||
}
|
||||
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
// GetBounce retrieves bounce entries based on the given params.
|
||||
func (c *Core) GetBounce(id int) (models.Bounce, error) {
|
||||
var out []models.Bounce
|
||||
stmt := strings.ReplaceAll(c.q.QueryBounces, "%order%", "id "+SortAsc)
|
||||
if err := c.db.Select(&out, stmt, id, 0, 0, "", 0, 1); err != nil {
|
||||
c.log.Printf("error fetching bounces: %v", err)
|
||||
return models.Bounce{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.bounce}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
if len(out) == 0 {
|
||||
return models.Bounce{}, echo.NewHTTPError(http.StatusBadRequest,
|
||||
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.bounce}"))
|
||||
|
||||
}
|
||||
|
||||
return out[0], nil
|
||||
}
|
||||
|
||||
// RecordBounce records a new bounce.
|
||||
func (c *Core) RecordBounce(b models.Bounce) error {
|
||||
action, ok := c.consts.BounceActions[b.Type]
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, c.i18n.Ts("globals.messages.invalidData")+": "+b.Type)
|
||||
}
|
||||
|
||||
_, err := c.q.RecordBounce.Exec(b.SubscriberUUID,
|
||||
b.Email,
|
||||
b.CampaignUUID,
|
||||
b.Type,
|
||||
b.Source,
|
||||
b.Meta,
|
||||
b.CreatedAt,
|
||||
action.Count,
|
||||
action.Action)
|
||||
|
||||
if err != nil {
|
||||
// Ignore the error if it complained of no subscriber.
|
||||
if pqErr, ok := err.(*pq.Error); ok && pqErr.Column == "subscriber_id" {
|
||||
c.log.Printf("bounced subscriber (%s / %s) not found", b.SubscriberUUID, b.Email)
|
||||
return nil
|
||||
}
|
||||
|
||||
c.log.Printf("error recording bounce: %v", err)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// BlocklistBouncedSubscribers blocklists all bounced subscribers.
|
||||
func (c *Core) BlocklistBouncedSubscribers() error {
|
||||
if _, err := c.q.BlocklistBouncedSubscribers.Exec(); err != nil {
|
||||
c.log.Printf("error blocklisting bounced subscribers: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, c.i18n.Ts("subscribers.errorBlocklisting", "error", err.Error()))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteBounce deletes a list.
|
||||
func (c *Core) DeleteBounce(id int) error {
|
||||
return c.DeleteBounces([]int{id}, false)
|
||||
}
|
||||
|
||||
// DeleteBounces deletes multiple lists.
|
||||
func (c *Core) DeleteBounces(ids []int, all bool) error {
|
||||
if _, err := c.q.DeleteBounces.Exec(pq.Array(ids), all); err != nil {
|
||||
c.log.Printf("error deleting lists: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.list}", "error", pqErrMsg(err)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gofrs/uuid/v5"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"source.offmarket.win/aleagle/eaglecast/models"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
const (
|
||||
CampaignAnalyticsViews = "views"
|
||||
CampaignAnalyticsClicks = "clicks"
|
||||
CampaignAnalyticsBounces = "bounces"
|
||||
|
||||
campaignTplDefault = "default"
|
||||
campaignTplArchive = "archive"
|
||||
)
|
||||
|
||||
// QueryCampaigns retrieves paginated campaigns optionally filtering them by the given arbitrary
|
||||
// query expression. It also returns the total number of records in the DB.
|
||||
func (c *Core) QueryCampaigns(searchStr string, statuses, tags []string, orderBy, order string, getAll bool, permittedLists []int, offset, limit int) (models.Campaigns, int, error) {
|
||||
queryStr, stmt := makeSearchQuery(searchStr, orderBy, order, c.q.QueryCampaigns, campQuerySortFields)
|
||||
|
||||
if statuses == nil {
|
||||
statuses = []string{}
|
||||
}
|
||||
|
||||
if tags == nil {
|
||||
tags = []string{}
|
||||
}
|
||||
|
||||
// Unsafe to ignore scanning fields not present in models.Campaigns.
|
||||
var out models.Campaigns
|
||||
if err := c.db.Select(&out, stmt, 0, pq.StringArray(statuses), pq.StringArray(tags), queryStr, getAll, pq.Array(permittedLists), offset, limit); err != nil {
|
||||
c.log.Printf("error fetching campaigns: %v", err)
|
||||
return nil, 0, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
for i := range out {
|
||||
// Replace null tags.
|
||||
if out[i].Tags == nil {
|
||||
out[i].Tags = []string{}
|
||||
}
|
||||
}
|
||||
|
||||
// Lazy load stats.
|
||||
if err := out.LoadStats(c.q.GetCampaignStats); err != nil {
|
||||
c.log.Printf("error fetching campaign stats: %v", err)
|
||||
return nil, 0, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaigns}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
total := 0
|
||||
if len(out) > 0 {
|
||||
total = out[0].Total
|
||||
}
|
||||
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
// GetCampaign retrieves a campaign.
|
||||
func (c *Core) GetCampaign(id int, uuid, archiveSlug string) (models.Campaign, error) {
|
||||
return c.getCampaign(id, uuid, archiveSlug, campaignTplDefault)
|
||||
}
|
||||
|
||||
// GetArchivedCampaign retrieves a campaign with the archive template body.
|
||||
func (c *Core) GetArchivedCampaign(id int, uuid, archiveSlug string) (models.Campaign, error) {
|
||||
out, err := c.getCampaign(id, uuid, archiveSlug, campaignTplArchive)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
if !out.Archive {
|
||||
return models.Campaign{}, echo.NewHTTPError(http.StatusBadRequest,
|
||||
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}"))
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// getCampaign retrieves a campaign. If typlType=default, then the campaign's
|
||||
// template body is returned as "template_body". If tplType="archive",
|
||||
// the archive template is returned.
|
||||
func (c *Core) getCampaign(id int, uuid, archiveSlug string, tplType string) (models.Campaign, error) {
|
||||
// Unsafe to ignore scanning fields not present in models.Campaigns.
|
||||
var uu any
|
||||
if uuid != "" {
|
||||
uu = uuid
|
||||
}
|
||||
|
||||
var out models.Campaigns
|
||||
if err := c.q.GetCampaign.Select(&out, id, uu, archiveSlug, tplType); err != nil {
|
||||
// if err := c.db.Select(&out, stmt, 0, pq.Array([]string{}), queryStr, 0, 1); err != nil {
|
||||
c.log.Printf("error fetching campaign: %v", err)
|
||||
return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
if len(out) == 0 {
|
||||
return models.Campaign{}, echo.NewHTTPError(http.StatusBadRequest,
|
||||
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}"))
|
||||
}
|
||||
|
||||
for i := 0; i < len(out); i++ {
|
||||
// Replace null tags.
|
||||
if out[i].Tags == nil {
|
||||
out[i].Tags = []string{}
|
||||
}
|
||||
}
|
||||
|
||||
// Lazy load stats.
|
||||
if err := out.LoadStats(c.q.GetCampaignStats); err != nil {
|
||||
c.log.Printf("error fetching campaign stats: %v", err)
|
||||
return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return out[0], nil
|
||||
}
|
||||
|
||||
// GetCampaignForPreview retrieves a campaign with a template body. If the optional tplID is > 0
|
||||
// that particular template is used, otherwise, the template saved on the campaign is.
|
||||
func (c *Core) GetCampaignForPreview(id, tplID int) (models.Campaign, error) {
|
||||
var out models.Campaign
|
||||
if err := c.q.GetCampaignForPreview.Get(&out, id, tplID); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return models.Campaign{}, echo.NewHTTPError(http.StatusBadRequest,
|
||||
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}"))
|
||||
}
|
||||
|
||||
c.log.Printf("error fetching campaign: %v", err)
|
||||
return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetArchivedCampaigns retrieves campaigns with a template body.
|
||||
func (c *Core) GetArchivedCampaigns(offset, limit int) (models.Campaigns, int, error) {
|
||||
var out models.Campaigns
|
||||
if err := c.q.GetArchivedCampaigns.Select(&out, offset, limit, campaignTplArchive); err != nil {
|
||||
c.log.Printf("error fetching public campaigns: %v", err)
|
||||
return models.Campaigns{}, 0, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
total := 0
|
||||
if len(out) > 0 {
|
||||
total = out[0].Total
|
||||
}
|
||||
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
// CreateCampaign creates a new campaign.
|
||||
func (c *Core) CreateCampaign(o models.Campaign, listIDs []int, mediaIDs []int) (models.Campaign, error) {
|
||||
uu, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
c.log.Printf("error generating UUID: %v", err)
|
||||
return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUUID", "error", err.Error()))
|
||||
}
|
||||
|
||||
// Insert and read ID.
|
||||
var newID int
|
||||
if err := c.q.CreateCampaign.Get(&newID,
|
||||
uu,
|
||||
o.Type,
|
||||
o.Name,
|
||||
o.Subject,
|
||||
o.FromEmail,
|
||||
o.Body,
|
||||
o.AltBody,
|
||||
o.ContentType,
|
||||
o.SendAt,
|
||||
o.Headers,
|
||||
o.Attribs,
|
||||
pq.StringArray(normalizeTags(o.Tags)),
|
||||
o.Messenger,
|
||||
o.TemplateID,
|
||||
pq.Array(listIDs),
|
||||
o.Archive,
|
||||
o.ArchiveSlug,
|
||||
o.ArchiveTemplateID,
|
||||
o.ArchiveMeta,
|
||||
pq.Array(mediaIDs),
|
||||
o.BodySource,
|
||||
); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return models.Campaign{}, echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("campaigns.noSubs"))
|
||||
}
|
||||
|
||||
c.log.Printf("error creating campaign: %v", err)
|
||||
return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
out, err := c.GetCampaign(newID, "", "")
|
||||
if err != nil {
|
||||
return models.Campaign{}, err
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UpdateCampaign updates a campaign.
|
||||
func (c *Core) UpdateCampaign(id int, o models.Campaign, listIDs []int, mediaIDs []int) (models.Campaign, error) {
|
||||
_, err := c.q.UpdateCampaign.Exec(id,
|
||||
o.Name,
|
||||
o.Subject,
|
||||
o.FromEmail,
|
||||
o.Body,
|
||||
o.AltBody,
|
||||
o.ContentType,
|
||||
o.SendAt,
|
||||
o.Headers,
|
||||
o.Attribs,
|
||||
pq.StringArray(normalizeTags(o.Tags)),
|
||||
o.Messenger,
|
||||
o.TemplateID,
|
||||
pq.Array(listIDs),
|
||||
o.Archive,
|
||||
o.ArchiveSlug,
|
||||
o.ArchiveTemplateID,
|
||||
o.ArchiveMeta,
|
||||
pq.Array(mediaIDs),
|
||||
o.BodySource)
|
||||
if err != nil {
|
||||
c.log.Printf("error updating campaign: %v", err)
|
||||
return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
out, err := c.GetCampaign(id, "", "")
|
||||
if err != nil {
|
||||
return models.Campaign{}, err
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UpdateCampaignStatus updates a campaign's status, eg: draft to running.
|
||||
func (c *Core) UpdateCampaignStatus(id int, status string) (models.Campaign, error) {
|
||||
cm, err := c.GetCampaign(id, "", "")
|
||||
if err != nil {
|
||||
return models.Campaign{}, err
|
||||
}
|
||||
|
||||
errMsg := ""
|
||||
switch status {
|
||||
case models.CampaignStatusDraft:
|
||||
if cm.Status != models.CampaignStatusScheduled {
|
||||
errMsg = c.i18n.T("campaigns.onlyScheduledAsDraft")
|
||||
}
|
||||
case models.CampaignStatusScheduled:
|
||||
if cm.Status != models.CampaignStatusDraft && cm.Status != models.CampaignStatusPaused {
|
||||
errMsg = c.i18n.T("campaigns.onlyDraftAsScheduled")
|
||||
}
|
||||
if !cm.SendAt.Valid {
|
||||
errMsg = c.i18n.T("campaigns.needsSendAt")
|
||||
}
|
||||
|
||||
case models.CampaignStatusRunning:
|
||||
if cm.Status != models.CampaignStatusPaused && cm.Status != models.CampaignStatusDraft {
|
||||
errMsg = c.i18n.T("campaigns.onlyPausedDraft")
|
||||
}
|
||||
case models.CampaignStatusPaused:
|
||||
if cm.Status != models.CampaignStatusRunning {
|
||||
errMsg = c.i18n.T("campaigns.onlyActivePause")
|
||||
}
|
||||
case models.CampaignStatusCancelled:
|
||||
if cm.Status != models.CampaignStatusRunning && cm.Status != models.CampaignStatusPaused {
|
||||
errMsg = c.i18n.T("campaigns.onlyActiveCancel")
|
||||
}
|
||||
}
|
||||
|
||||
if len(errMsg) > 0 {
|
||||
return models.Campaign{}, echo.NewHTTPError(http.StatusBadRequest, errMsg)
|
||||
}
|
||||
|
||||
res, err := c.q.UpdateCampaignStatus.Exec(cm.ID, status)
|
||||
if err != nil {
|
||||
c.log.Printf("error updating campaign status: %v", err)
|
||||
|
||||
return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return models.Campaign{}, echo.NewHTTPError(http.StatusBadRequest,
|
||||
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
cm.Status = status
|
||||
return cm, nil
|
||||
}
|
||||
|
||||
// UpdateCampaignArchive updates a campaign's archive properties.
|
||||
func (c *Core) UpdateCampaignArchive(id int, enabled bool, tplID int, meta models.JSON, archiveSlug string) error {
|
||||
if _, err := c.q.UpdateCampaignArchive.Exec(id, enabled, archiveSlug, tplID, meta); err != nil {
|
||||
c.log.Printf("error updating campaign: %v", err)
|
||||
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteCampaign deletes a campaign.
|
||||
func (c *Core) DeleteCampaign(id int) error {
|
||||
res, err := c.q.DeleteCampaign.Exec(id)
|
||||
if err != nil {
|
||||
c.log.Printf("error deleting campaign: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
||||
|
||||
}
|
||||
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest,
|
||||
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteCampaigns deletes multiple campaigns by IDs or by query.
|
||||
func (c *Core) DeleteCampaigns(ids []int, query string, hasAllPerm bool, permittedLists []int) error {
|
||||
var queryStr string
|
||||
|
||||
if len(ids) > 0 {
|
||||
queryStr = ""
|
||||
} else {
|
||||
queryStr = makeSearchString(query)
|
||||
}
|
||||
|
||||
if _, err := c.q.DeleteCampaigns.Exec(pq.Array(ids), queryStr, hasAllPerm, pq.Array(permittedLists)); err != nil {
|
||||
c.log.Printf("error deleting campaigns: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.campaigns}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CampaignHasLists checks if a campaign has any of the given list IDs.
|
||||
func (c *Core) CampaignHasLists(id int, listIDs []int) (bool, error) {
|
||||
has := false
|
||||
if err := c.q.CampaignHasLists.Get(&has, id, pq.Array(listIDs)); err != nil {
|
||||
c.log.Printf("error checking campaign lists: %v", err)
|
||||
return false, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return has, nil
|
||||
}
|
||||
|
||||
// GetRunningCampaignStats returns the progress stats of running campaigns.
|
||||
func (c *Core) GetRunningCampaignStats() ([]models.CampaignStats, error) {
|
||||
out := []models.CampaignStats{}
|
||||
if err := c.q.GetCampaignStatus.Select(&out, models.CampaignStatusRunning); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
c.log.Printf("error fetching campaign stats: %v", err)
|
||||
return nil, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
||||
} else if len(out) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *Core) GetCampaignAnalyticsCounts(campIDs []int, typ, fromDate, toDate string) ([]models.CampaignAnalyticsCount, error) {
|
||||
// Pick campaign view counts or click counts.
|
||||
var stmt *sqlx.Stmt
|
||||
switch typ {
|
||||
case "views":
|
||||
stmt = c.q.GetCampaignViewCounts
|
||||
case "clicks":
|
||||
stmt = c.q.GetCampaignClickCounts
|
||||
case "bounces":
|
||||
stmt = c.q.GetCampaignBounceCounts
|
||||
default:
|
||||
return nil, echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("globals.messages.invalidData"))
|
||||
}
|
||||
|
||||
if !strHasLen(fromDate, 10, 30) || !strHasLen(toDate, 10, 30) {
|
||||
return nil, echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("analytics.invalidDates"))
|
||||
}
|
||||
|
||||
out := []models.CampaignAnalyticsCount{}
|
||||
if err := stmt.Select(&out, pq.Array(campIDs), fromDate, toDate); err != nil {
|
||||
c.log.Printf("error fetching campaign %s: %v", typ, err)
|
||||
return nil, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.analytics}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetCampaignAnalyticsLinks returns link click analytics for the given campaign IDs.
|
||||
func (c *Core) GetCampaignAnalyticsLinks(campIDs []int, typ, fromDate, toDate string) ([]models.CampaignAnalyticsLink, error) {
|
||||
out := []models.CampaignAnalyticsLink{}
|
||||
if err := c.q.GetCampaignLinkCounts.Select(&out, pq.Array(campIDs), fromDate, toDate); err != nil {
|
||||
c.log.Printf("error fetching campaign %s: %v", typ, err)
|
||||
return nil, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.analytics}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RegisterCampaignView registers a subscriber's view on a campaign.
|
||||
func (c *Core) RegisterCampaignView(campUUID, subUUID string) error {
|
||||
if _, err := c.q.RegisterCampaignView.Exec(campUUID, subUUID); err != nil {
|
||||
if pqErr, ok := err.(*pq.Error); ok && pqErr.Column == "campaign_id" {
|
||||
return nil
|
||||
}
|
||||
|
||||
c.log.Printf("error registering campaign view: %s", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLinkURL returns the original URL for a link UUID without recording a click.
|
||||
func (c *Core) GetLinkURL(linkUUID string) (string, error) {
|
||||
var url string
|
||||
if err := c.q.GetLinkURL.Get(&url, linkUUID); err != nil {
|
||||
c.log.Printf("error getting link URL: %s", err)
|
||||
return "", echo.NewHTTPError(http.StatusInternalServerError, c.i18n.Ts("public.errorProcessingRequest"))
|
||||
}
|
||||
return url, nil
|
||||
}
|
||||
|
||||
// RegisterCampaignLinkClick registers a subscriber's link click on a campaign.
|
||||
func (c *Core) RegisterCampaignLinkClick(linkUUID, campUUID, subUUID string) (string, error) {
|
||||
var url string
|
||||
if err := c.q.RegisterLinkClick.Get(&url, linkUUID, campUUID, subUUID); err != nil {
|
||||
if pqErr, ok := err.(*pq.Error); ok && pqErr.Column == "link_id" {
|
||||
return "", echo.NewHTTPError(http.StatusBadRequest, c.i18n.Ts("public.invalidLink"))
|
||||
}
|
||||
|
||||
c.log.Printf("error registering link click: %s", err)
|
||||
return "", echo.NewHTTPError(http.StatusInternalServerError, c.i18n.Ts("public.errorProcessingRequest"))
|
||||
}
|
||||
|
||||
return url, nil
|
||||
}
|
||||
|
||||
// ExportCampaignViews returns an iterator with campaign views for streaming/exporting.
|
||||
func (c *Core) ExportCampaignViews(since time.Time, batchSize int) func() ([]models.CampaignViewExport, error) {
|
||||
offset := 0
|
||||
return func() ([]models.CampaignViewExport, error) {
|
||||
var out []models.CampaignViewExport
|
||||
if err := c.q.ExportCampaignViews.Select(&out, since, batchSize, offset); err != nil {
|
||||
c.log.Printf("error exporting campaign views: %v", err)
|
||||
return nil, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.analytics}", "error", pqErrMsg(err)))
|
||||
}
|
||||
offset += len(out)
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
|
||||
// ExportCampaignLinkClicks returns an iterator with campaign link click for streaming/exporting.
|
||||
func (c *Core) ExportCampaignLinkClicks(since time.Time, batchSize int) func() ([]models.CampaignClickExport, error) {
|
||||
offset := 0
|
||||
return func() ([]models.CampaignClickExport, error) {
|
||||
var out []models.CampaignClickExport
|
||||
if err := c.q.ExportCampaignLinkClicks.Select(&out, since, batchSize, offset); err != nil {
|
||||
c.log.Printf("error exporting campaign link clicks: %v", err)
|
||||
return nil, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.analytics}", "error", pqErrMsg(err)))
|
||||
}
|
||||
offset += len(out)
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteCampaignViews deletes campaign views older than a given date.
|
||||
func (c *Core) DeleteCampaignViews(before time.Time) error {
|
||||
if _, err := c.q.DeleteCampaignViews.Exec(before); err != nil {
|
||||
c.log.Printf("error deleting campaign views: %s", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, c.i18n.Ts("public.errorProcessingRequest"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteCampaignLinkClicks deletes campaign views older than a given date.
|
||||
func (c *Core) DeleteCampaignLinkClicks(before time.Time) error {
|
||||
if _, err := c.q.DeleteCampaignLinkClicks.Exec(before); err != nil {
|
||||
c.log.Printf("error deleting campaign link clicks: %s", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, c.i18n.Ts("public.errorProcessingRequest"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
// package core is the collection of re-usable functions that primarily provides data (DB / CRUD) operations
|
||||
// to the app. For instance, creating and mutating objects like lists, subscribers etc.
|
||||
// All such methods return an echo.HTTPError{} (which implements error.error) that can be directly returned
|
||||
// as a response to HTTP handlers without further processing.
|
||||
package core
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"source.offmarket.win/aleagle/eaglecast/internal/i18n"
|
||||
"source.offmarket.win/aleagle/eaglecast/models"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
const (
|
||||
SortAsc = "asc"
|
||||
SortDesc = "desc"
|
||||
|
||||
matDashboardCharts = "mat_dashboard_charts"
|
||||
matDashboardCounts = "mat_dashboard_counts"
|
||||
matListSubStats = "mat_list_subscriber_stats"
|
||||
)
|
||||
|
||||
// Core represents the eaglecast core with all shared, global functions.
|
||||
type Core struct {
|
||||
h *Hooks
|
||||
|
||||
consts Constants
|
||||
i18n *i18n.I18n
|
||||
db *sqlx.DB
|
||||
q *models.Queries
|
||||
log *log.Logger
|
||||
}
|
||||
|
||||
// Constants represents constant config.
|
||||
type Constants struct {
|
||||
SendOptinConfirmation bool
|
||||
BounceActions map[string]struct {
|
||||
Count int
|
||||
Action string
|
||||
}
|
||||
CacheSlowQueries bool
|
||||
}
|
||||
|
||||
// Hooks contains external function hooks that are required by the core package.
|
||||
type Hooks struct {
|
||||
SendOptinConfirmation func(models.Subscriber, []int) (int, error)
|
||||
}
|
||||
|
||||
// Opt contains the controllers required to start the core.
|
||||
type Opt struct {
|
||||
Constants Constants
|
||||
I18n *i18n.I18n
|
||||
DB *sqlx.DB
|
||||
Queries *models.Queries
|
||||
Log *log.Logger
|
||||
}
|
||||
|
||||
var (
|
||||
ErrNotFound = echo.NewHTTPError(http.StatusNotFound, "not found")
|
||||
)
|
||||
|
||||
var (
|
||||
regexFullTextQuery = regexp.MustCompile(`\s+`)
|
||||
regexpSpaces = regexp.MustCompile(`[\s]+`)
|
||||
campQuerySortFields = []string{"name", "status", "created_at", "updated_at"}
|
||||
subQuerySortFields = []string{"email", "status", "name", "created_at", "updated_at"}
|
||||
listQuerySortFields = []string{"name", "status", "created_at", "updated_at", "subscriber_count"}
|
||||
)
|
||||
|
||||
// New returns a new instance of the core.
|
||||
func New(o *Opt, h *Hooks) *Core {
|
||||
return &Core{
|
||||
h: h,
|
||||
consts: o.Constants,
|
||||
i18n: o.I18n,
|
||||
db: o.DB,
|
||||
q: o.Queries,
|
||||
log: o.Log,
|
||||
}
|
||||
}
|
||||
|
||||
// RefreshMatViews refreshes all materialized views.
|
||||
func (c *Core) RefreshMatViews(concurrent bool) error {
|
||||
for _, v := range []string{matDashboardCharts, matDashboardCounts, matListSubStats} {
|
||||
_ = c.RefreshMatView(v, true)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RefreshMatView refreshes a Postgres materialized view.
|
||||
func (c *Core) RefreshMatView(name string, concurrent bool) error {
|
||||
q := "REFRESH MATERIALIZED VIEW %s %s"
|
||||
if concurrent {
|
||||
q = fmt.Sprintf(q, "CONCURRENTLY", name)
|
||||
} else {
|
||||
q = fmt.Sprintf(q, "", name)
|
||||
}
|
||||
|
||||
if _, err := c.db.Exec(q); err != nil {
|
||||
c.log.Printf("error refreshing materialized view: %s: %v", name, err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// refreshCache refreshes a Postgres materialized view if caching is disabled.
|
||||
func (c *Core) refreshCache(name string, concurrent bool) error {
|
||||
if c.consts.CacheSlowQueries {
|
||||
return nil
|
||||
}
|
||||
|
||||
return c.RefreshMatView(name, concurrent)
|
||||
}
|
||||
|
||||
// Given an error, pqErrMsg will try to return pq error details
|
||||
// if it's a pq error.
|
||||
func pqErrMsg(err error) string {
|
||||
if err, ok := err.(*pq.Error); ok {
|
||||
if err.Detail != "" {
|
||||
return fmt.Sprintf("%s. %s", err, err.Detail)
|
||||
}
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
// makeSearchQuery cleans an optional search string and prepares the
|
||||
// query SQL statement (string interpolated) and returns the
|
||||
// search query string along with the SQL expression.
|
||||
func makeSearchQuery(searchStr, orderBy, order, query string, querySortFields []string) (string, string) {
|
||||
searchStr = makeSearchString(searchStr)
|
||||
|
||||
// Sort params.
|
||||
if !strSliceContains(orderBy, querySortFields) {
|
||||
orderBy = "created_at"
|
||||
}
|
||||
if order != SortAsc && order != SortDesc {
|
||||
order = SortDesc
|
||||
}
|
||||
|
||||
query = strings.ReplaceAll(query, "%order%", orderBy+" "+order)
|
||||
|
||||
return searchStr, query
|
||||
}
|
||||
|
||||
// makeSearchString prepares a search string for use in both tsquery and ILIKE queries.
|
||||
func makeSearchString(searchStr string) string {
|
||||
if searchStr == "" {
|
||||
return ""
|
||||
}
|
||||
return `%` + string(regexFullTextQuery.ReplaceAll([]byte(searchStr), []byte("&"))) + `%`
|
||||
}
|
||||
|
||||
// strSliceContains checks if a string is present in the string slice.
|
||||
func strSliceContains(str string, sl []string) bool {
|
||||
for _, s := range sl {
|
||||
if s == str {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// normalizeTags takes a list of string tags and normalizes them by
|
||||
// lower casing and removing all special characters except for dashes.
|
||||
func normalizeTags(tags []string) []string {
|
||||
var (
|
||||
out []string
|
||||
dash = []byte("-")
|
||||
)
|
||||
|
||||
for _, t := range tags {
|
||||
rep := regexpSpaces.ReplaceAll(bytes.TrimSpace([]byte(t)), dash)
|
||||
|
||||
if len(rep) > 0 {
|
||||
out = append(out, string(rep))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sanitizeSQLExp does basic sanitisation on arbitrary
|
||||
// SQL query expressions coming from the frontend.
|
||||
func sanitizeSQLExp(q string) string {
|
||||
if len(q) == 0 {
|
||||
return ""
|
||||
}
|
||||
q = strings.TrimSpace(q)
|
||||
|
||||
// Remove semicolon suffix.
|
||||
if q[len(q)-1] == ';' {
|
||||
q = q[:len(q)-1]
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
// strHasLen checks if the given string has a length within min-max.
|
||||
func strHasLen(str string, min, max int) bool {
|
||||
return len(str) >= min && len(str) <= max
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/jmoiron/sqlx/types"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// GetDashboardCharts returns chart data points to render on the dashboard.
|
||||
func (c *Core) GetDashboardCharts() (types.JSONText, error) {
|
||||
_ = c.refreshCache(matDashboardCharts, false)
|
||||
|
||||
var out types.JSONText
|
||||
if err := c.q.GetDashboardCharts.Get(&out); err != nil {
|
||||
return nil, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "dashboard charts", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetDashboardCounts returns stats counts to show on the dashboard.
|
||||
func (c *Core) GetDashboardCounts() (types.JSONText, error) {
|
||||
_ = c.refreshCache(matDashboardCounts, false)
|
||||
|
||||
var out types.JSONText
|
||||
if err := c.q.GetDashboardCounts.Get(&out); err != nil {
|
||||
return nil, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "dashboard stats", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gofrs/uuid/v5"
|
||||
"source.offmarket.win/aleagle/eaglecast/models"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
type listType struct {
|
||||
ID int `json:"id"`
|
||||
UUID string `json:"uuid"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// GetLists gets all lists optionally filtered by type and status.
|
||||
func (c *Core) GetLists(typ, status string, getAll bool, permittedIDs []int) ([]models.List, error) {
|
||||
out := []models.List{}
|
||||
|
||||
if err := c.q.GetLists.Select(&out, typ, status, "id", getAll, pq.Array(permittedIDs)); err != nil {
|
||||
c.log.Printf("error fetching lists: %v", err)
|
||||
return nil, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.lists}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
// Replace null tags.
|
||||
for i, l := range out {
|
||||
if l.Tags == nil {
|
||||
out[i].Tags = []string{}
|
||||
}
|
||||
|
||||
// Total counts.
|
||||
for _, c := range l.SubscriberCounts {
|
||||
out[i].SubscriberCount += c
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// QueryLists gets multiple lists based on multiple query params. Along with the paginated and sliced
|
||||
// results, the total number of lists in the DB is returned.
|
||||
func (c *Core) QueryLists(searchStr, typ, optin, status string, tags []string, orderBy, order string, getAll bool, permittedIDs []int, offset, limit int) ([]models.List, int, error) {
|
||||
_ = c.refreshCache(matListSubStats, false)
|
||||
|
||||
if tags == nil {
|
||||
tags = []string{}
|
||||
}
|
||||
|
||||
var (
|
||||
out = []models.List{}
|
||||
queryStr, stmt = makeSearchQuery(searchStr, orderBy, order, c.q.QueryLists, listQuerySortFields)
|
||||
)
|
||||
if err := c.db.Select(&out, stmt, 0, "", queryStr, typ, optin, status, pq.StringArray(tags), getAll, pq.Array(permittedIDs), offset, limit); err != nil {
|
||||
c.log.Printf("error fetching lists: %v", err)
|
||||
return nil, 0, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.lists}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
total := 0
|
||||
if len(out) > 0 {
|
||||
total = out[0].Total
|
||||
|
||||
// Replace null tags.
|
||||
for i, l := range out {
|
||||
if l.Tags == nil {
|
||||
out[i].Tags = []string{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
// GetList gets a list by its ID or UUID.
|
||||
func (c *Core) GetList(id int, uuid string) (models.List, error) {
|
||||
var uu any
|
||||
if uuid != "" {
|
||||
uu = uuid
|
||||
}
|
||||
|
||||
var res []models.List
|
||||
queryStr, stmt := makeSearchQuery("", "", "", c.q.QueryLists, nil)
|
||||
if err := c.db.Select(&res, stmt, id, uu, queryStr, "", "", "", pq.StringArray{}, true, nil, 0, 1); err != nil {
|
||||
c.log.Printf("error fetching lists: %v", err)
|
||||
return models.List{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.lists}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
if len(res) == 0 {
|
||||
return models.List{}, echo.NewHTTPError(http.StatusBadRequest,
|
||||
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.list}"))
|
||||
}
|
||||
|
||||
out := res[0]
|
||||
if out.Tags == nil {
|
||||
out.Tags = []string{}
|
||||
}
|
||||
// Total counts.
|
||||
for _, c := range out.SubscriberCounts {
|
||||
out.SubscriberCount += c
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetListsByOptin returns lists by optin type.
|
||||
func (c *Core) GetListsByOptin(ids []int, optinType string) ([]models.List, error) {
|
||||
out := []models.List{}
|
||||
if err := c.q.GetListsByOptin.Select(&out, optinType, pq.Array(ids), nil); err != nil {
|
||||
c.log.Printf("error fetching lists for opt-in: %s", pqErrMsg(err))
|
||||
return nil, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.list}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetListTypes returns lists by their IDs or UUIDs.
|
||||
// If ids is given, then the map returned has the list IDs as keys,
|
||||
// otherwise, they have UUIDs as the keys.
|
||||
// Note: This is a really weird and awkward API. Ideally, Go Generics
|
||||
// should've somehow supported generic struct methods.
|
||||
func (c *Core) GetListTypes(ids []int, uuids []string) (map[any]string, error) {
|
||||
res := []listType{}
|
||||
|
||||
out := map[any]string{}
|
||||
if err := c.q.GetListTypes.Select(&res, pq.Array(ids), pq.StringArray(uuids)); err != nil {
|
||||
c.log.Printf("error fetching list types: %v", err)
|
||||
return nil, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.list}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
isIDs := ids != nil
|
||||
for _, r := range res {
|
||||
if isIDs {
|
||||
out[r.ID] = r.Type
|
||||
} else {
|
||||
out[r.UUID] = r.Type
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CreateList creates a new list.
|
||||
func (c *Core) CreateList(l models.List) (models.List, error) {
|
||||
uu, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
c.log.Printf("error generating UUID: %v", err)
|
||||
return models.List{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUUID", "error", err.Error()))
|
||||
}
|
||||
|
||||
if l.Type == "" {
|
||||
l.Type = models.ListTypePrivate
|
||||
}
|
||||
if l.Optin == "" {
|
||||
l.Optin = models.ListOptinSingle
|
||||
}
|
||||
if l.Status == "" {
|
||||
l.Status = models.ListStatusActive
|
||||
}
|
||||
|
||||
// Insert and read ID.
|
||||
var newID int
|
||||
l.UUID = uu.String()
|
||||
if err := c.q.CreateList.Get(&newID, l.UUID, l.Name, l.Type, l.Optin, l.Status, pq.StringArray(normalizeTags(l.Tags)), l.Description); err != nil {
|
||||
c.log.Printf("error creating list: %v", err)
|
||||
return models.List{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.list}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return c.GetList(newID, "")
|
||||
}
|
||||
|
||||
// UpdateList updates a given list.
|
||||
func (c *Core) UpdateList(id int, l models.List) (models.List, error) {
|
||||
res, err := c.q.UpdateList.Exec(id, l.Name, l.Type, l.Optin, l.Status, pq.StringArray(normalizeTags(l.Tags)), l.Description)
|
||||
if err != nil {
|
||||
c.log.Printf("error updating list: %v", err)
|
||||
return models.List{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.list}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return models.List{}, echo.NewHTTPError(http.StatusBadRequest,
|
||||
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.list}"))
|
||||
}
|
||||
|
||||
return c.GetList(id, "")
|
||||
}
|
||||
|
||||
// DeleteList deletes a list.
|
||||
func (c *Core) DeleteList(id int) error {
|
||||
return c.DeleteLists([]int{id}, "", true, nil)
|
||||
}
|
||||
|
||||
// DeleteLists deletes multiple lists.
|
||||
func (c *Core) DeleteLists(ids []int, query string, getAll bool, permittedIDs []int) error {
|
||||
var queryStr string
|
||||
|
||||
if len(ids) > 0 {
|
||||
queryStr = ""
|
||||
} else {
|
||||
queryStr = makeSearchString(query)
|
||||
}
|
||||
|
||||
if _, err := c.q.DeleteLists.Exec(pq.Array(ids), queryStr, getAll, pq.Array(permittedIDs)); err != nil {
|
||||
c.log.Printf("error deleting lists: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.lists}", "error", pqErrMsg(err)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gofrs/uuid/v5"
|
||||
"source.offmarket.win/aleagle/eaglecast/internal/media"
|
||||
"source.offmarket.win/aleagle/eaglecast/models"
|
||||
"github.com/labstack/echo/v4"
|
||||
"gopkg.in/volatiletech/null.v6"
|
||||
)
|
||||
|
||||
// QueryMedia returns media entries optionally filtered by a query string.
|
||||
func (c *Core) QueryMedia(provider string, s media.Store, query string, offset, limit int) ([]media.Media, int, error) {
|
||||
out := []media.Media{}
|
||||
|
||||
if query != "" {
|
||||
query = strings.ToLower(query)
|
||||
}
|
||||
|
||||
if err := c.q.QueryMedia.Select(&out, fmt.Sprintf("%%%s%%", query), provider, offset, limit); err != nil {
|
||||
return out, 0, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching",
|
||||
"name", "{globals.terms.media}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
total := 0
|
||||
if len(out) > 0 {
|
||||
total = out[0].Total
|
||||
|
||||
for i := 0; i < len(out); i++ {
|
||||
out[i].URL = s.GetURL(out[i].Filename)
|
||||
|
||||
if out[i].Thumb != "" {
|
||||
out[i].ThumbURL = null.String{Valid: true, String: s.GetURL(out[i].Thumb)}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
// GetMedia returns a media item.
|
||||
func (c *Core) GetMedia(id int, uuid, fileName string, s media.Store) (media.Media, error) {
|
||||
var uu any
|
||||
if uuid != "" {
|
||||
uu = uuid
|
||||
}
|
||||
|
||||
var out media.Media
|
||||
if err := c.q.GetMedia.Get(&out, id, uu, fileName); err != nil {
|
||||
// If it's ` sql: no rows in result set`, return a 404.
|
||||
if err == sql.ErrNoRows {
|
||||
return out, ErrNotFound
|
||||
}
|
||||
|
||||
return out, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.media}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
out.URL = s.GetURL(out.Filename)
|
||||
if out.Thumb != "" {
|
||||
out.ThumbURL = null.String{Valid: true, String: s.GetURL(out.Thumb)}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// InsertMedia inserts a new media file into the DB.
|
||||
func (c *Core) InsertMedia(fileName, thumbName, contentType string, meta models.JSON, provider string, s media.Store) (media.Media, error) {
|
||||
uu, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
c.log.Printf("error generating UUID: %v", err)
|
||||
return media.Media{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUUID", "error", err.Error()))
|
||||
}
|
||||
|
||||
// Write to the DB.
|
||||
var newID int
|
||||
if err := c.q.InsertMedia.Get(&newID, uu, fileName, thumbName, contentType, provider, meta); err != nil {
|
||||
c.log.Printf("error inserting uploaded file to db: %v", err)
|
||||
return media.Media{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.media}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return c.GetMedia(newID, "", "", s)
|
||||
}
|
||||
|
||||
// DeleteMedia deletes a given media item and returns the filename of the deleted item.
|
||||
func (c *Core) DeleteMedia(id int) (string, error) {
|
||||
var fname string
|
||||
if err := c.q.DeleteMedia.Get(&fname, id); err != nil {
|
||||
c.log.Printf("error inserting uploaded file to db: %v", err)
|
||||
return "", echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.media}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return fname, nil
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"source.offmarket.win/aleagle/eaglecast/internal/auth"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// GetRoles retrieves all roles.
|
||||
func (c *Core) GetRoles() ([]auth.Role, error) {
|
||||
out := []auth.Role{}
|
||||
if err := c.q.GetUserRoles.Select(&out, nil); err != nil {
|
||||
return nil, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "role", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetRole retrieves a role.
|
||||
func (c *Core) GetRole(id int) (auth.Role, error) {
|
||||
out := []auth.Role{}
|
||||
if err := c.q.GetUserRoles.Select(&out, id); err != nil {
|
||||
return auth.Role{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "role", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
// Role does not exist.
|
||||
if len(out) == 0 {
|
||||
return auth.Role{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "role", "error", "role not found"))
|
||||
}
|
||||
|
||||
return out[0], nil
|
||||
}
|
||||
|
||||
// GetListRoles retrieves all list roles.
|
||||
func (c *Core) GetListRoles() ([]auth.ListRole, error) {
|
||||
out := []auth.ListRole{}
|
||||
if err := c.q.GetListRoles.Select(&out); err != nil {
|
||||
return nil, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "role", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
// Unmarshall the nested list permissions, if any.
|
||||
for n, r := range out {
|
||||
if r.ListsRaw == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(r.ListsRaw, &out[n].Lists); err != nil {
|
||||
c.log.Printf("error unmarshalling list permissions for role %d: %v", r.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CreateRole creates a new role.
|
||||
func (c *Core) CreateRole(r auth.Role) (auth.Role, error) {
|
||||
var out auth.Role
|
||||
|
||||
if err := c.q.CreateRole.Get(&out, r.Name, auth.RoleTypeUser, pq.Array(r.Permissions)); err != nil {
|
||||
return out, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorCreating", "name", "{users.role}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CreateListRole creates a new list role.
|
||||
func (c *Core) CreateListRole(r auth.ListRole) (auth.ListRole, error) {
|
||||
var out auth.ListRole
|
||||
|
||||
if err := c.q.CreateRole.Get(&out, r.Name, auth.RoleTypeList, pq.Array([]string{})); err != nil {
|
||||
return out, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorCreating", "name", "{users.role}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
if err := c.UpsertListPermissions(out.ID, r.Lists); err != nil {
|
||||
return out, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorCreating", "name", "{users.role}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UpsertListPermissions upserts permission for a role.
|
||||
func (c *Core) UpsertListPermissions(roleID int, lp []auth.ListPermission) error {
|
||||
var (
|
||||
listIDs = make([]int, 0, len(lp))
|
||||
listPerms = make([][]string, 0, len(lp))
|
||||
)
|
||||
for _, p := range lp {
|
||||
if len(p.Permissions) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
listIDs = append(listIDs, p.ID)
|
||||
|
||||
// For the Postgres array unnesting query to work, all permissions arrays should
|
||||
// have equal number of entries. Add "" in case there's only one of either list:get or list:manage
|
||||
perms := make([]string, 2)
|
||||
copy(perms[:], p.Permissions[:])
|
||||
listPerms = append(listPerms, perms)
|
||||
}
|
||||
|
||||
if _, err := c.q.UpsertListPermissions.Exec(roleID, pq.Array(listIDs), pq.Array(listPerms)); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorCreating", "name", "{users.role}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteListPermission deletes a list permission entry from a role.
|
||||
func (c *Core) DeleteListPermission(roleID, listID int) error {
|
||||
if _, err := c.q.DeleteListPermission.Exec(roleID, listID); err != nil {
|
||||
if pqErr, ok := err.(*pq.Error); ok && pqErr.Constraint == "users_role_id_fkey" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("users.cantDeleteRole"))
|
||||
}
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorDeleting", "name", "{users.role}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateUserRole updates a given role.
|
||||
func (c *Core) UpdateUserRole(id int, r auth.Role) (auth.Role, error) {
|
||||
var out auth.Role
|
||||
|
||||
if err := c.q.UpdateRole.Get(&out, id, r.Name, pq.Array(r.Permissions)); err != nil {
|
||||
return out, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{users.userRole}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
if out.ID == 0 {
|
||||
return out, echo.NewHTTPError(http.StatusBadRequest, c.i18n.Ts("globals.messages.notFound", "name", "{users.userRole}"))
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UpdateListRole updates a given role.
|
||||
func (c *Core) UpdateListRole(id int, r auth.ListRole) (auth.ListRole, error) {
|
||||
var out auth.ListRole
|
||||
|
||||
if err := c.q.UpdateRole.Get(&out, id, r.Name, pq.Array([]string{})); err != nil {
|
||||
return out, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{users.listRole}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
if out.ID == 0 {
|
||||
return out, echo.NewHTTPError(http.StatusBadRequest, c.i18n.Ts("globals.messages.notFound", "name", "{users.listRole}"))
|
||||
}
|
||||
|
||||
if err := c.UpsertListPermissions(out.ID, r.Lists); err != nil {
|
||||
return out, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorCreating", "name", "{users.listRole}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DeleteRole deletes a given role.
|
||||
func (c *Core) DeleteRole(id int) error {
|
||||
if _, err := c.q.DeleteRole.Exec(id); err != nil {
|
||||
if pqErr, ok := err.(*pq.Error); ok && pqErr.Constraint == "users_role_id_fkey" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("users.cantDeleteRole"))
|
||||
}
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorDeleting", "name", "{users.role}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/jmoiron/sqlx/types"
|
||||
"source.offmarket.win/aleagle/eaglecast/models"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// GetSettings returns settings from the DB.
|
||||
func (c *Core) GetSettings() (models.Settings, error) {
|
||||
var (
|
||||
b types.JSONText
|
||||
out models.Settings
|
||||
)
|
||||
|
||||
if err := c.q.GetSettings.Get(&b); err != nil {
|
||||
return out, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching",
|
||||
"name", "{globals.terms.settings}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
// Unmarshal the settings and filter out sensitive fields.
|
||||
if err := json.Unmarshal([]byte(b), &out); err != nil {
|
||||
return out, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("settings.errorEncoding", "error", err.Error()))
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UpdateSettings updates settings.
|
||||
func (c *Core) UpdateSettings(s models.Settings) error {
|
||||
// Marshal settings.
|
||||
b, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("settings.errorEncoding", "error", err.Error()))
|
||||
}
|
||||
|
||||
// Update the settings in the DB.
|
||||
if _, err := c.q.UpdateSettings.Exec(b); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.settings}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateSettingsByKey updates a single setting by key.
|
||||
func (c *Core) UpdateSettingsByKey(key string, value json.RawMessage) error {
|
||||
if _, err := c.q.UpdateSettingsByKey.Exec(key, value); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.settings}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,671 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gofrs/uuid/v5"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"source.offmarket.win/aleagle/eaglecast/internal/auth"
|
||||
"source.offmarket.win/aleagle/eaglecast/models"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
var (
|
||||
allowedSubQueryTables = map[string]struct{}{
|
||||
"subscribers": {},
|
||||
"lists": {},
|
||||
"subscriber_lists": {},
|
||||
"campaigns": {},
|
||||
"campaign_lists": {},
|
||||
"campaign_views": {},
|
||||
"links": {},
|
||||
"link_clicks": {},
|
||||
"bounces": {},
|
||||
}
|
||||
)
|
||||
|
||||
// GetSubscriber fetches a subscriber by one of the given params.
|
||||
func (c *Core) GetSubscriber(id int, uuid, email string) (models.Subscriber, error) {
|
||||
var uu any
|
||||
if uuid != "" {
|
||||
uu = uuid
|
||||
}
|
||||
|
||||
var out models.Subscribers
|
||||
if err := c.q.GetSubscriber.Select(&out, id, uu, email); err != nil {
|
||||
c.log.Printf("error fetching subscriber: %v", err)
|
||||
return models.Subscriber{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching",
|
||||
"name", "{globals.terms.subscriber}", "error", pqErrMsg(err)))
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return models.Subscriber{}, echo.NewHTTPError(http.StatusBadRequest,
|
||||
c.i18n.Ts("globals.messages.notFound", "name",
|
||||
fmt.Sprintf("{globals.terms.subscriber} (%d: %s%s)", id, uuid, email)))
|
||||
}
|
||||
if err := out.LoadLists(c.q.GetSubscriberListsLazy); err != nil {
|
||||
c.log.Printf("error loading subscriber lists: %v", err)
|
||||
return models.Subscriber{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching",
|
||||
"name", "{globals.terms.lists}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return out[0], nil
|
||||
}
|
||||
|
||||
// HasSubscriberLists checks if the given subscribers have at least one of the given lists.
|
||||
func (c *Core) HasSubscriberLists(subIDs []int, listIDs []int) (map[int]bool, error) {
|
||||
res := []struct {
|
||||
SubID int `db:"subscriber_id"`
|
||||
Has bool `db:"has"`
|
||||
}{}
|
||||
|
||||
if err := c.q.HasSubscriberLists.Select(&res, pq.Array(subIDs), pq.Array(listIDs)); err != nil {
|
||||
c.log.Printf("error fetching subscriber: %v", err)
|
||||
return nil, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.subscriber}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
out := make(map[int]bool, len(res))
|
||||
for _, r := range res {
|
||||
out[r.SubID] = r.Has
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetSubscribersByEmail fetches a subscriber by one of the given params.
|
||||
func (c *Core) GetSubscribersByEmail(emails []string) (models.Subscribers, error) {
|
||||
var out models.Subscribers
|
||||
|
||||
if err := c.q.GetSubscribersByEmails.Select(&out, pq.Array(emails)); err != nil {
|
||||
c.log.Printf("error fetching subscriber: %v", err)
|
||||
return nil, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.subscriber}", "error", pqErrMsg(err)))
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("campaigns.noKnownSubsToTest"))
|
||||
}
|
||||
|
||||
if err := out.LoadLists(c.q.GetSubscriberListsLazy); err != nil {
|
||||
c.log.Printf("error loading subscriber lists: %v", err)
|
||||
return nil, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.lists}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// QuerySubscribers queries and returns paginated subscrribers based on the given params including the total count.
|
||||
func (c *Core) QuerySubscribers(searchStr, queryExp string, listIDs []int, subStatus string, order, orderBy string, offset, limit int) (models.Subscribers, int, error) {
|
||||
// Sort params.
|
||||
if !strSliceContains(orderBy, subQuerySortFields) {
|
||||
orderBy = "subscribers.id"
|
||||
}
|
||||
if order != SortAsc && order != SortDesc {
|
||||
order = SortDesc
|
||||
}
|
||||
|
||||
// Required for pq.Array()
|
||||
if listIDs == nil {
|
||||
listIDs = []int{}
|
||||
}
|
||||
|
||||
// There's an arbitrary query condition.
|
||||
cond := "TRUE"
|
||||
if queryExp != "" {
|
||||
cond = queryExp
|
||||
}
|
||||
|
||||
// stmt is the raw SQL query.
|
||||
stmt := strings.ReplaceAll(c.q.QuerySubscribers, "%query%", cond)
|
||||
stmt = strings.ReplaceAll(stmt, "%order%", orderBy+" "+order)
|
||||
|
||||
// Validate the tables used in the query.
|
||||
if err := validateQueryTables(c.db, stmt, allowedSubQueryTables, pq.Array(listIDs), subStatus, searchStr, offset, limit); err != nil {
|
||||
c.log.Printf("error validating query tables: %v", err)
|
||||
return nil, 0, echo.NewHTTPError(http.StatusBadRequest,
|
||||
c.i18n.Ts("subscribers.errorPreparingQuery", "error", err.Error()))
|
||||
}
|
||||
|
||||
// Create a readonly transaction that just does COUNT() to obtain the count of results
|
||||
// and to ensure that the arbitrary query is indeed readonly.
|
||||
total, err := c.getSubscriberCount(searchStr, cond, subStatus, listIDs)
|
||||
if err != nil {
|
||||
c.log.Printf("error getting subscriber count: %v", err)
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// No results.
|
||||
if total == 0 {
|
||||
return models.Subscribers{}, 0, nil
|
||||
}
|
||||
|
||||
tx, err := c.db.BeginTxx(context.Background(), &sql.TxOptions{ReadOnly: true})
|
||||
if err != nil {
|
||||
c.log.Printf("error preparing subscriber query: %v", err)
|
||||
return nil, 0, echo.NewHTTPError(http.StatusBadRequest, c.i18n.Ts("subscribers.errorPreparingQuery", "error", pqErrMsg(err)))
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var out models.Subscribers
|
||||
if err := tx.Select(&out, stmt, pq.Array(listIDs), subStatus, searchStr, offset, limit); err != nil {
|
||||
return nil, 0, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
// Lazy load lists for each subscriber.
|
||||
if err := out.LoadLists(c.q.GetSubscriberListsLazy); err != nil {
|
||||
c.log.Printf("error fetching subscriber lists: %v", err)
|
||||
return nil, 0, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
// GetSubscriberLists returns a subscriber's lists based on the given conditions.
|
||||
func (c *Core) GetSubscriberLists(subID int, uuid string, listIDs []int, listUUIDs []string, subStatus string, listType string) ([]models.List, error) {
|
||||
if listIDs == nil {
|
||||
listIDs = []int{}
|
||||
}
|
||||
if listUUIDs == nil {
|
||||
listUUIDs = []string{}
|
||||
}
|
||||
|
||||
var uu any
|
||||
if uuid != "" {
|
||||
uu = uuid
|
||||
}
|
||||
|
||||
// Fetch double opt-in lists from the given list IDs.
|
||||
// Get the list of subscription lists where the subscriber hasn't confirmed.
|
||||
out := []models.List{}
|
||||
if err := c.q.GetSubscriberLists.Select(&out, subID, uu, pq.Array(listIDs), pq.Array(listUUIDs), subStatus, listType); err != nil {
|
||||
c.log.Printf("error fetching lists for opt-in: %s", pqErrMsg(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetSubscriberProfileForExport returns the subscriber's profile data as a JSON exportable.
|
||||
// Get the subscriber's data. A single query that gets the profile, list subscriptions, campaign views,
|
||||
// and link clicks. Names of private lists are replaced with "Private list".
|
||||
func (c *Core) GetSubscriberProfileForExport(id int, uuid string) (models.SubscriberExportProfile, error) {
|
||||
var uu any
|
||||
if uuid != "" {
|
||||
uu = uuid
|
||||
}
|
||||
|
||||
var out models.SubscriberExportProfile
|
||||
if err := c.q.ExportSubscriberData.Get(&out, id, uu); err != nil {
|
||||
c.log.Printf("error fetching subscriber export data: %v", err)
|
||||
|
||||
return models.SubscriberExportProfile{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.subscribers}", "error", err.Error()))
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetSubscriberActivity returns the subscriber's campaign views and link clicks for the Activity tab.
|
||||
func (c *Core) GetSubscriberActivity(id int) (models.SubscriberActivity, error) {
|
||||
var out models.SubscriberActivity
|
||||
if err := c.q.GetSubscriberActivity.Get(&out, id); err != nil {
|
||||
c.log.Printf("error fetching subscriber activity: %v", err)
|
||||
|
||||
return models.SubscriberActivity{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "activity", "error", err.Error()))
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ExportSubscribers returns an iterator function that provides lists of subscribers based
|
||||
// on the given criteria in an exportable form. The iterator function returned can be called
|
||||
// repeatedly until there are nil subscribers. It's an iterator because exports can be extremely
|
||||
// large and may have to be fetched in batches from the DB and streamed somewhere.
|
||||
func (c *Core) ExportSubscribers(searchStr, query string, subIDs, listIDs []int, subStatus string, batchSize int) (func() ([]models.SubscriberExport, error), error) {
|
||||
if subIDs == nil {
|
||||
subIDs = []int{}
|
||||
}
|
||||
if listIDs == nil {
|
||||
listIDs = []int{}
|
||||
}
|
||||
|
||||
// There's an arbitrary query condition.
|
||||
cond := "TRUE"
|
||||
if query != "" {
|
||||
cond = query
|
||||
}
|
||||
|
||||
stmt := strings.ReplaceAll(c.q.QuerySubscribersForExport, "%query%", cond)
|
||||
|
||||
// Validate the tables used in the query.
|
||||
if err := validateQueryTables(c.db, stmt, allowedSubQueryTables,
|
||||
pq.Array(listIDs), 0, pq.Array(subIDs), subStatus, searchStr, batchSize); err != nil {
|
||||
c.log.Printf("error validating query tables: %v", err)
|
||||
return nil, echo.NewHTTPError(http.StatusBadRequest,
|
||||
c.i18n.Ts("subscribers.errorPreparingQuery", "error", err.Error()))
|
||||
}
|
||||
|
||||
// Create a readonly transaction that just does COUNT() to obtain the count of results
|
||||
// and to ensure that the arbitrary query is indeed readonly.
|
||||
if _, err := c.getSubscriberCount(searchStr, cond, subStatus, listIDs); err != nil {
|
||||
c.log.Printf("error getting subscriber count: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Prepare the actual query statement.
|
||||
tx, err := c.db.Preparex(stmt)
|
||||
if err != nil {
|
||||
c.log.Printf("error preparing subscriber query: %v", err)
|
||||
return nil, echo.NewHTTPError(http.StatusBadRequest,
|
||||
c.i18n.Ts("subscribers.errorPreparingQuery", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
id := 0
|
||||
return func() ([]models.SubscriberExport, error) {
|
||||
var out []models.SubscriberExport
|
||||
if err := tx.Select(&out, pq.Array(listIDs), id, pq.Array(subIDs), subStatus, searchStr, batchSize); err != nil {
|
||||
c.log.Printf("error exporting subscribers by query: %v", err)
|
||||
return nil, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
id = out[len(out)-1].ID
|
||||
return out, nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
// InsertSubscriber inserts a subscriber and returns the ID. The first bool indicates if
|
||||
// it was a new subscriber, and the second bool indicates if the subscriber was sent an optin confirmation.
|
||||
// bool = optinSent?
|
||||
func (c *Core) InsertSubscriber(sub models.Subscriber, listIDs []int, listUUIDs []string, preconfirm, assertOptin bool) (models.Subscriber, bool, error) {
|
||||
uu, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
c.log.Printf("error generating UUID: %v", err)
|
||||
return models.Subscriber{}, false, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUUID", "error", err.Error()))
|
||||
}
|
||||
sub.UUID = uu.String()
|
||||
|
||||
subStatus := models.SubscriptionStatusUnconfirmed
|
||||
if preconfirm {
|
||||
subStatus = models.SubscriptionStatusConfirmed
|
||||
}
|
||||
if sub.Status == "" {
|
||||
sub.Status = auth.UserStatusEnabled
|
||||
}
|
||||
|
||||
// For pq.Array()
|
||||
if listIDs == nil {
|
||||
listIDs = []int{}
|
||||
}
|
||||
if listUUIDs == nil {
|
||||
listUUIDs = []string{}
|
||||
}
|
||||
|
||||
if err = c.q.InsertSubscriber.Get(&sub.ID,
|
||||
sub.UUID,
|
||||
sub.Email,
|
||||
strings.TrimSpace(sub.Name),
|
||||
sub.Status,
|
||||
sub.Attribs,
|
||||
pq.Array(listIDs),
|
||||
pq.Array(listUUIDs),
|
||||
subStatus); err != nil {
|
||||
if pqErr, ok := err.(*pq.Error); ok && pqErr.Constraint == "subscribers_email_key" {
|
||||
return models.Subscriber{}, false, echo.NewHTTPError(http.StatusConflict, c.i18n.T("subscribers.emailExists"))
|
||||
} else {
|
||||
// return sub.Subscriber, errSubscriberExists
|
||||
c.log.Printf("error inserting subscriber: %v", err)
|
||||
return models.Subscriber{}, false, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.subscriber}", "error", pqErrMsg(err)))
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch the subscriber's full data. If the subscriber already existed and wasn't
|
||||
// created, the id will be empty. Fetch the details by e-mail then.
|
||||
out, err := c.GetSubscriber(sub.ID, "", sub.Email)
|
||||
if err != nil {
|
||||
return models.Subscriber{}, false, err
|
||||
}
|
||||
|
||||
hasOptin := false
|
||||
if !preconfirm && c.consts.SendOptinConfirmation {
|
||||
// Send a confirmation e-mail (if there are any double opt-in lists).
|
||||
num, err := c.h.SendOptinConfirmation(out, listIDs)
|
||||
if assertOptin && err != nil {
|
||||
return out, hasOptin, err
|
||||
}
|
||||
|
||||
hasOptin = num > 0
|
||||
}
|
||||
|
||||
return out, hasOptin, nil
|
||||
}
|
||||
|
||||
// UpdateSubscriber updates a subscriber's properties.
|
||||
func (c *Core) UpdateSubscriber(id int, sub models.Subscriber) (models.Subscriber, error) {
|
||||
// Format raw JSON attributes.
|
||||
attribs := []byte("{}")
|
||||
if len(sub.Attribs) > 0 {
|
||||
if b, err := json.Marshal(sub.Attribs); err != nil {
|
||||
return models.Subscriber{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating",
|
||||
"name", "{globals.terms.subscriber}", "error", err.Error()))
|
||||
} else {
|
||||
attribs = b
|
||||
}
|
||||
}
|
||||
|
||||
_, err := c.q.UpdateSubscriber.Exec(id,
|
||||
sub.Email,
|
||||
strings.TrimSpace(sub.Name),
|
||||
sub.Status,
|
||||
json.RawMessage(attribs),
|
||||
)
|
||||
if err != nil {
|
||||
c.log.Printf("error updating subscriber: %v", err)
|
||||
return models.Subscriber{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscriber}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
out, err := c.GetSubscriber(sub.ID, "", sub.Email)
|
||||
if err != nil {
|
||||
return models.Subscriber{}, err
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UpdateSubscriberWithLists updates a subscriber's properties.
|
||||
// If deleteLists is set to true, all existing subscriptions are deleted and only
|
||||
// the ones provided are added or retained.
|
||||
func (c *Core) UpdateSubscriberWithLists(id int, sub models.Subscriber, listIDs []int, listUUIDs []string, preconfirm, deleteLists, assertOptin bool, permittedListIDs []int, allowResubscribe bool) (models.Subscriber, bool, error) {
|
||||
subStatus := models.SubscriptionStatusUnconfirmed
|
||||
if preconfirm {
|
||||
subStatus = models.SubscriptionStatusConfirmed
|
||||
}
|
||||
|
||||
// Format raw JSON attributes.
|
||||
attribs := []byte("{}")
|
||||
if len(sub.Attribs) > 0 {
|
||||
if b, err := json.Marshal(sub.Attribs); err != nil {
|
||||
return models.Subscriber{}, false, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating",
|
||||
"name", "{globals.terms.subscriber}", "error", err.Error()))
|
||||
} else {
|
||||
attribs = b
|
||||
}
|
||||
}
|
||||
|
||||
_, err := c.q.UpdateSubscriberWithLists.Exec(id,
|
||||
sub.Email,
|
||||
strings.TrimSpace(sub.Name),
|
||||
sub.Status,
|
||||
json.RawMessage(attribs),
|
||||
pq.Array(listIDs),
|
||||
pq.Array(listUUIDs),
|
||||
subStatus,
|
||||
deleteLists,
|
||||
pq.Array(permittedListIDs),
|
||||
allowResubscribe)
|
||||
if err != nil {
|
||||
c.log.Printf("error updating subscriber: %v", err)
|
||||
return models.Subscriber{}, false, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscriber}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
out, err := c.GetSubscriber(sub.ID, "", sub.Email)
|
||||
if err != nil {
|
||||
return models.Subscriber{}, false, err
|
||||
}
|
||||
|
||||
hasOptin := false
|
||||
if !preconfirm && c.consts.SendOptinConfirmation {
|
||||
// Send a confirmation e-mail (if there are any double opt-in lists).
|
||||
num, err := c.h.SendOptinConfirmation(out, listIDs)
|
||||
if assertOptin && err != nil {
|
||||
return out, hasOptin, err
|
||||
}
|
||||
hasOptin = num > 0
|
||||
}
|
||||
|
||||
return out, hasOptin, nil
|
||||
}
|
||||
|
||||
// BlocklistSubscribers blocklists the given list of subscribers.
|
||||
func (c *Core) BlocklistSubscribers(subIDs []int) error {
|
||||
if _, err := c.q.BlocklistSubscribers.Exec(pq.Array(subIDs)); err != nil {
|
||||
c.log.Printf("error blocklisting subscribers: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("subscribers.errorBlocklisting", "error", err.Error()))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// BlocklistSubscribersByQuery blocklists the given list of subscribers.
|
||||
func (c *Core) BlocklistSubscribersByQuery(searchStr, queryExp string, listIDs []int, subStatus string) error {
|
||||
if err := c.q.ExecSubQueryTpl(searchStr, sanitizeSQLExp(queryExp), c.q.BlocklistSubscribersByQuery, listIDs, c.db, subStatus); err != nil {
|
||||
c.log.Printf("error blocklisting subscribers: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("subscribers.errorBlocklisting", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteSubscribers deletes the given list of subscribers.
|
||||
func (c *Core) DeleteSubscribers(subIDs []int, subUUIDs []string) error {
|
||||
if subIDs == nil {
|
||||
subIDs = []int{}
|
||||
}
|
||||
if subUUIDs == nil {
|
||||
subUUIDs = []string{}
|
||||
}
|
||||
|
||||
if _, err := c.q.DeleteSubscribers.Exec(pq.Array(subIDs), pq.Array(subUUIDs)); err != nil {
|
||||
c.log.Printf("error deleting subscribers: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteSubscribersByQuery deletes subscribers by a given arbitrary query expression.
|
||||
func (c *Core) DeleteSubscribersByQuery(searchStr, queryExp string, listIDs []int, subStatus string) error {
|
||||
err := c.q.ExecSubQueryTpl(searchStr, sanitizeSQLExp(queryExp), c.q.DeleteSubscribersByQuery, listIDs, c.db, subStatus)
|
||||
if err != nil {
|
||||
c.log.Printf("error deleting subscribers: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// UnsubscribeByCampaign unsubscribes a given subscriber from lists in a given campaign.
|
||||
func (c *Core) UnsubscribeByCampaign(subUUID, campUUID string, blocklist bool) error {
|
||||
if _, err := c.q.UnsubscribeByCampaign.Exec(campUUID, subUUID, blocklist); err != nil {
|
||||
c.log.Printf("error unsubscribing: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConfirmOptionSubscription confirms a subscriber's optin subscription.
|
||||
func (c *Core) ConfirmOptionSubscription(subUUID string, listUUIDs []string, meta models.JSON) error {
|
||||
if meta == nil {
|
||||
meta = models.JSON{}
|
||||
}
|
||||
|
||||
if _, err := c.q.ConfirmSubscriptionOptin.Exec(subUUID, pq.Array(listUUIDs), meta); err != nil {
|
||||
c.log.Printf("error confirming subscription: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteSubscriberBounces deletes the given list of subscribers.
|
||||
func (c *Core) DeleteSubscriberBounces(id int, uuid string) error {
|
||||
var uu any
|
||||
if uuid != "" {
|
||||
uu = uuid
|
||||
}
|
||||
|
||||
if _, err := c.q.DeleteBouncesBySubscriber.Exec(id, uu); err != nil {
|
||||
c.log.Printf("error deleting bounces: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.bounces}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteOrphanSubscribers deletes orphan subscriber records (subscribers without lists).
|
||||
func (c *Core) DeleteOrphanSubscribers() (int, error) {
|
||||
res, err := c.q.DeleteOrphanSubscribers.Exec()
|
||||
if err != nil {
|
||||
c.log.Printf("error deleting orphan subscribers: %v", err)
|
||||
return 0, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
n, _ := res.RowsAffected()
|
||||
return int(n), nil
|
||||
}
|
||||
|
||||
// DeleteBlocklistedSubscribers deletes blocklisted subscribers.
|
||||
func (c *Core) DeleteBlocklistedSubscribers() (int, error) {
|
||||
res, err := c.q.DeleteBlocklistedSubscribers.Exec()
|
||||
if err != nil {
|
||||
c.log.Printf("error deleting blocklisted subscribers: %v", err)
|
||||
return 0, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
n, _ := res.RowsAffected()
|
||||
return int(n), nil
|
||||
}
|
||||
|
||||
func (c *Core) getSubscriberCount(searchStr, queryExp, subStatus string, listIDs []int) (int, error) {
|
||||
// If there's no condition, it's a "get all" call which can probably be optionally pulled from cache.
|
||||
if queryExp == "" {
|
||||
_ = c.refreshCache(matListSubStats, false)
|
||||
|
||||
total := 0
|
||||
if err := c.q.QuerySubscribersCountAll.Get(&total, pq.Array(listIDs), subStatus); err != nil {
|
||||
return 0, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// Create a readonly transaction that just does COUNT() to obtain the count of results
|
||||
// and to ensure that the arbitrary query is indeed readonly.
|
||||
stmt := strings.ReplaceAll(c.q.QuerySubscribersCount, "%query%", queryExp)
|
||||
tx, err := c.db.BeginTxx(context.Background(), &sql.TxOptions{ReadOnly: true})
|
||||
if err != nil {
|
||||
c.log.Printf("error preparing subscriber query: %v", err)
|
||||
return 0, echo.NewHTTPError(http.StatusBadRequest, c.i18n.Ts("subscribers.errorPreparingQuery", "error", pqErrMsg(err)))
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Execute the readonly query and get the count of results.
|
||||
total := 0
|
||||
if err := tx.Get(&total, stmt, pq.Array(listIDs), subStatus, searchStr); err != nil {
|
||||
return 0, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// validateQueryTables checks if the query accesses only allowed tables.
|
||||
func validateQueryTables(db *sqlx.DB, query string, allowedTables map[string]struct{}, args ...any) error {
|
||||
// Get the EXPLAIN (FORMAT JSON) output.
|
||||
tx, err := db.BeginTxx(context.Background(), &sql.TxOptions{ReadOnly: true})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var plan string
|
||||
if err = tx.QueryRow("EXPLAIN (FORMAT JSON) "+query, args...).Scan(&plan); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Extract all relation names from the JSON plan.
|
||||
tables, err := getTablesFromQueryPlan(plan)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting tables from query: %v", err)
|
||||
}
|
||||
|
||||
// Validate against allowed tables.
|
||||
for _, table := range tables {
|
||||
if _, ok := allowedTables[table]; !ok {
|
||||
return fmt.Errorf("table '%s' is not allowed", table)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getTablesFromQueryPlan parses the EXPLAIN JSON to find all "Relation Name" entries.
|
||||
func getTablesFromQueryPlan(explainJSON string) ([]string, error) {
|
||||
var plans []map[string]any
|
||||
if err := json.Unmarshal([]byte(explainJSON), &plans); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Collect table names in `tables` recursively.
|
||||
tables := make(map[string]struct{})
|
||||
for _, plan := range plans {
|
||||
traverseQueryPlan(plan, tables)
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(tables))
|
||||
for table := range tables {
|
||||
result = append(result, table)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func traverseQueryPlan(node map[string]any, tables map[string]struct{}) {
|
||||
if relName, ok := node["Relation Name"].(string); ok {
|
||||
tables[relName] = struct{}{}
|
||||
}
|
||||
|
||||
// Recursively check nested plans (e.g., subqueries, CTEs).
|
||||
for _, v := range node {
|
||||
switch v := v.(type) {
|
||||
case map[string]any:
|
||||
traverseQueryPlan(v, tables)
|
||||
case []any:
|
||||
for _, item := range v {
|
||||
if m, ok := item.(map[string]any); ok {
|
||||
traverseQueryPlan(m, tables)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"source.offmarket.win/aleagle/eaglecast/models"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// GetSubscriptions retrieves the subscriptions for a subscriber.
|
||||
func (c *Core) GetSubscriptions(subID int, subUUID string, allLists bool) ([]models.Subscription, error) {
|
||||
var out []models.Subscription
|
||||
err := c.q.GetSubscriptions.Select(&out, subID, subUUID, allLists)
|
||||
if err != nil {
|
||||
c.log.Printf("error getting subscriptions: %v", err)
|
||||
return nil, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.subscribers}", "error", err.Error()))
|
||||
}
|
||||
|
||||
return out, err
|
||||
}
|
||||
|
||||
// AddSubscriptions adds list subscriptions to subscribers.
|
||||
func (c *Core) AddSubscriptions(subIDs, listIDs []int, status string) error {
|
||||
if _, err := c.q.AddSubscribersToLists.Exec(pq.Array(subIDs), pq.Array(listIDs), status); err != nil {
|
||||
c.log.Printf("error adding subscriptions: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscribers}", "error", err.Error()))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddSubscriptionsByQuery adds list subscriptions to subscribers by a given arbitrary query expression.
|
||||
// sourceListIDs is the list of list IDs to filter the subscriber query with.
|
||||
func (c *Core) AddSubscriptionsByQuery(searchStr, queryExp string, sourceListIDs, targetListIDs []int, status string, subStatus string) error {
|
||||
if sourceListIDs == nil {
|
||||
sourceListIDs = []int{}
|
||||
}
|
||||
|
||||
err := c.q.ExecSubQueryTpl(searchStr, queryExp, c.q.AddSubscribersToListsByQuery, sourceListIDs, c.db, subStatus, pq.Array(targetListIDs), status)
|
||||
if err != nil {
|
||||
c.log.Printf("error adding subscriptions by query: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteSubscriptions delete list subscriptions from subscribers.
|
||||
func (c *Core) DeleteSubscriptions(subIDs, listIDs []int) error {
|
||||
if _, err := c.q.DeleteSubscriptions.Exec(pq.Array(subIDs), pq.Array(listIDs)); err != nil {
|
||||
c.log.Printf("error deleting subscriptions: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscribers}", "error", err.Error()))
|
||||
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteSubscriptionsByQuery deletes list subscriptions from subscribers by a given arbitrary query expression.
|
||||
// sourceListIDs is the list of list IDs to filter the subscriber query with.
|
||||
func (c *Core) DeleteSubscriptionsByQuery(searchStr, queryExp string, sourceListIDs, targetListIDs []int, subStatus string) error {
|
||||
if sourceListIDs == nil {
|
||||
sourceListIDs = []int{}
|
||||
}
|
||||
|
||||
err := c.q.ExecSubQueryTpl(searchStr, queryExp, c.q.DeleteSubscriptionsByQuery, sourceListIDs, c.db, subStatus, pq.Array(targetListIDs))
|
||||
if err != nil {
|
||||
c.log.Printf("error deleting subscriptions by query: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnsubscribeLists sets list subscriptions to 'unsubscribed'.
|
||||
func (c *Core) UnsubscribeLists(subIDs, listIDs []int, listUUIDs []string) error {
|
||||
if _, err := c.q.UnsubscribeSubscribersFromLists.Exec(pq.Array(subIDs), pq.Array(listIDs), pq.StringArray(listUUIDs)); err != nil {
|
||||
c.log.Printf("error unsubscribing from lists: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscribers}", "error", err.Error()))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnsubscribeListsByQuery sets list subscriptions to 'unsubscribed' by a given arbitrary query expression.
|
||||
// sourceListIDs is the list of list IDs to filter the subscriber query with.
|
||||
func (c *Core) UnsubscribeListsByQuery(searchStr, queryExp string, sourceListIDs, targetListIDs []int, subStatus string) error {
|
||||
if sourceListIDs == nil {
|
||||
sourceListIDs = []int{}
|
||||
}
|
||||
|
||||
err := c.q.ExecSubQueryTpl(searchStr, queryExp, c.q.UnsubscribeSubscribersFromListsByQuery, sourceListIDs, c.db, subStatus, pq.Array(targetListIDs))
|
||||
if err != nil {
|
||||
c.log.Printf("error unsubscribing from lists by query: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteUnconfirmedSubscriptions sets list subscriptions to 'unsubscribed' by a given arbitrary query expression.
|
||||
// sourceListIDs is the list of list IDs to filter the subscriber query with.
|
||||
func (c *Core) DeleteUnconfirmedSubscriptions(beforeDate time.Time) (int, error) {
|
||||
res, err := c.q.DeleteUnconfirmedSubscriptions.Exec(beforeDate)
|
||||
if err != nil {
|
||||
c.log.Printf("error deleting unconfirmed subscribers: %v", err)
|
||||
return 0, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
n, _ := res.RowsAffected()
|
||||
return int(n), nil
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
|
||||
"source.offmarket.win/aleagle/eaglecast/models"
|
||||
"github.com/labstack/echo/v4"
|
||||
null "gopkg.in/volatiletech/null.v6"
|
||||
)
|
||||
|
||||
// GetTemplates retrieves all templates.
|
||||
func (c *Core) GetTemplates(status string, noBody bool) ([]models.Template, error) {
|
||||
out := []models.Template{}
|
||||
if err := c.q.GetTemplates.Select(&out, 0, noBody, status); err != nil {
|
||||
return nil, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.templates}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetTemplate retrieves a given template.
|
||||
func (c *Core) GetTemplate(id int, noBody bool) (models.Template, error) {
|
||||
var out []models.Template
|
||||
if err := c.q.GetTemplates.Select(&out, id, noBody, ""); err != nil {
|
||||
return models.Template{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.templates}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
if len(out) == 0 {
|
||||
return models.Template{}, echo.NewHTTPError(http.StatusBadRequest,
|
||||
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.template}"))
|
||||
}
|
||||
|
||||
return out[0], nil
|
||||
}
|
||||
|
||||
// CreateTemplate creates a new template.
|
||||
func (c *Core) CreateTemplate(name, typ, subject string, body []byte, bodySource null.String) (models.Template, error) {
|
||||
var newID int
|
||||
if err := c.q.CreateTemplate.Get(&newID, name, typ, subject, body, bodySource); err != nil {
|
||||
return models.Template{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.template}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return c.GetTemplate(newID, false)
|
||||
}
|
||||
|
||||
// UpdateTemplate updates a given template.
|
||||
func (c *Core) UpdateTemplate(id int, name, subject string, body []byte, bodySource null.String) (models.Template, error) {
|
||||
res, err := c.q.UpdateTemplate.Exec(id, name, subject, body, bodySource)
|
||||
if err != nil {
|
||||
return models.Template{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.template}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return models.Template{}, echo.NewHTTPError(http.StatusBadRequest,
|
||||
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.template}"))
|
||||
}
|
||||
|
||||
return c.GetTemplate(id, false)
|
||||
}
|
||||
|
||||
// SetDefaultTemplate sets a template as default.
|
||||
func (c *Core) SetDefaultTemplate(id int) error {
|
||||
if _, err := c.q.SetDefaultTemplate.Exec(id); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.template}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteTemplate deletes a given template.
|
||||
func (c *Core) DeleteTemplate(id int) error {
|
||||
var delID int
|
||||
if err := c.q.DeleteTemplate.Get(&delID, id); err != nil && err != sql.ErrNoRows {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.template}", "error", pqErrMsg(err)))
|
||||
}
|
||||
if delID == 0 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("templates.cantDeleteDefault"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"source.offmarket.win/aleagle/eaglecast/internal/auth"
|
||||
"source.offmarket.win/aleagle/eaglecast/internal/utils"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/lib/pq"
|
||||
"gopkg.in/volatiletech/null.v6"
|
||||
)
|
||||
|
||||
func (c *Core) GetUsers() ([]auth.User, error) {
|
||||
out := []auth.User{}
|
||||
if err := c.q.GetUsers.Select(&out); err != nil {
|
||||
return nil, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.users}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return c.setupUserFields(out), nil
|
||||
}
|
||||
|
||||
// GetUser retrieves a specific user based on any one given identifier.
|
||||
func (c *Core) GetUser(id int, username, email string) (auth.User, error) {
|
||||
var out auth.User
|
||||
if err := c.q.GetUser.Get(&out, id, username, email); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return out, echo.NewHTTPError(http.StatusNotFound,
|
||||
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.user}"))
|
||||
|
||||
}
|
||||
|
||||
return out, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.users}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return c.setupUserFields([]auth.User{out})[0], nil
|
||||
}
|
||||
|
||||
// CreateUser creates a new user.
|
||||
func (c *Core) CreateUser(u auth.User) (auth.User, error) {
|
||||
var (
|
||||
apiToken string
|
||||
dbPassword = u.Password
|
||||
)
|
||||
|
||||
// If it's an API user, generate a random token for password
|
||||
// and set the e-mail to default.
|
||||
if u.Type == auth.UserTypeAPI {
|
||||
// Generate a random API token.
|
||||
tk, err := utils.GenerateRandomString(48)
|
||||
if err != nil {
|
||||
return auth.User{}, err
|
||||
}
|
||||
apiToken = tk
|
||||
|
||||
u.Email = null.String{String: u.Username + "@api", Valid: true}
|
||||
u.PasswordLogin = false
|
||||
u.Password = null.String{String: tk, Valid: true}
|
||||
dbPassword = null.String{String: auth.HashAPIToken(tk), Valid: true}
|
||||
}
|
||||
|
||||
var id int
|
||||
if err := c.q.CreateUser.Get(&id, u.Username, u.PasswordLogin, dbPassword, u.Email, u.Name, u.Type, u.UserRoleID, u.ListRoleID, u.Status); err != nil {
|
||||
return auth.User{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.user}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
// Hide the password field in the response except for when the user type is an API token,
|
||||
// where the frontend shows the token on the UI just once.
|
||||
if u.Type != auth.UserTypeAPI {
|
||||
u.Password = null.String{Valid: false}
|
||||
}
|
||||
|
||||
out, err := c.GetUser(id, "", "")
|
||||
if u.Type == auth.UserTypeAPI {
|
||||
out.Password = null.String{String: apiToken, Valid: true}
|
||||
}
|
||||
|
||||
return out, err
|
||||
}
|
||||
|
||||
// UpdateUser updates a given user.
|
||||
func (c *Core) UpdateUser(id int, u auth.User) (auth.User, error) {
|
||||
listRoleID := 0
|
||||
if u.ListRoleID == nil {
|
||||
listRoleID = -1
|
||||
} else {
|
||||
listRoleID = *u.ListRoleID
|
||||
}
|
||||
|
||||
res, err := c.q.UpdateUser.Exec(id, u.Username, u.PasswordLogin, u.Password, u.Email, u.Name, u.Type, u.UserRoleID, listRoleID, u.Status)
|
||||
if err != nil {
|
||||
return auth.User{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.user}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return auth.User{}, echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("users.needSuper"))
|
||||
}
|
||||
|
||||
out, err := c.GetUser(id, "", "")
|
||||
|
||||
return out, err
|
||||
}
|
||||
|
||||
// UpdateUserProfile updates the basic fields of a given uesr (name, email, password).
|
||||
func (c *Core) UpdateUserProfile(id int, u auth.User) (auth.User, error) {
|
||||
res, err := c.q.UpdateUserProfile.Exec(id, u.Name, u.Email, u.PasswordLogin, u.Password)
|
||||
if err != nil {
|
||||
return auth.User{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.user}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return auth.User{}, echo.NewHTTPError(http.StatusBadRequest,
|
||||
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.user}"))
|
||||
}
|
||||
|
||||
return c.GetUser(id, "", "")
|
||||
}
|
||||
|
||||
// UpdateUserLogin updates a user's record post-login.
|
||||
func (c *Core) UpdateUserLogin(id int, avatar string) error {
|
||||
if _, err := c.q.UpdateUserLogin.Exec(id, avatar); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.user}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetTwoFA sets or clears the 2FA configuration for a user.
|
||||
func (c *Core) SetTwoFA(id int, twofaType, twofaKey string) error {
|
||||
if _, err := c.q.SetUserTwoFA.Exec(id, twofaType, twofaKey); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.user}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteUserSessions deletes all sessions for a given user ID, optionally
|
||||
// excluding a specific session ID (to keep the current session alive).
|
||||
func (c *Core) DeleteUserSessions(userID int, excludeID string) error {
|
||||
if _, err := c.q.DeleteUserSessions.Exec(strconv.Itoa(userID), excludeID); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteUsers deletes a given user.
|
||||
func (c *Core) DeleteUsers(ids []int) error {
|
||||
res, err := c.q.DeleteUsers.Exec(pq.Array(ids))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.user}", "error", pqErrMsg(err)))
|
||||
}
|
||||
if num, err := res.RowsAffected(); err != nil || num == 0 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("users.needSuper"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoginUser attempts to log the given user_id in by matching the password.
|
||||
func (c *Core) LoginUser(username, password string) (auth.User, error) {
|
||||
var out auth.User
|
||||
if err := c.q.LoginUser.Get(&out, username, password); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return out, echo.NewHTTPError(http.StatusForbidden, c.i18n.T("users.invalidLogin"))
|
||||
}
|
||||
|
||||
return out, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.users}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// setupUserFields prepares and sets up various user fields.
|
||||
func (c *Core) setupUserFields(users []auth.User) []auth.User {
|
||||
for n, u := range users {
|
||||
u := u
|
||||
|
||||
if u.Password.String != "" {
|
||||
u.HasPassword = true
|
||||
u.PasswordLogin = true
|
||||
}
|
||||
|
||||
if u.Type == auth.UserTypeAPI {
|
||||
u.Email = null.String{}
|
||||
}
|
||||
|
||||
u.UserRole.ID = u.UserRoleID
|
||||
u.UserRole.Name = u.UserRoleName
|
||||
u.UserRole.Permissions = u.UserRolePerms
|
||||
u.UserRoleID = 0
|
||||
|
||||
// Prepare lookup maps.
|
||||
u.ListPermissionsMap = make(map[int]map[string]struct{})
|
||||
u.PermissionsMap = make(map[string]struct{})
|
||||
for _, p := range u.UserRolePerms {
|
||||
u.PermissionsMap[p] = struct{}{}
|
||||
}
|
||||
|
||||
if u.ListRoleID != nil {
|
||||
// Unmarshall the raw list perms map.
|
||||
var listPerms []auth.ListPermission
|
||||
if u.ListsPermsRaw != nil {
|
||||
if err := json.Unmarshal(*u.ListsPermsRaw, &listPerms); err != nil {
|
||||
c.log.Printf("error unmarshalling list permissions for role %d: %v", u.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
u.ListRole = &auth.ListRolePermissions{ID: *u.ListRoleID, Name: u.ListRoleName.String, Lists: listPerms}
|
||||
|
||||
// Iterate each list in the list permissions and setup get/manage list IDs.
|
||||
for _, p := range listPerms {
|
||||
u.ListPermissionsMap[p.ID] = make(map[string]struct{})
|
||||
|
||||
for _, perm := range p.Permissions {
|
||||
u.ListPermissionsMap[p.ID][perm] = struct{}{}
|
||||
|
||||
// List IDs with get / manage permissions.
|
||||
if perm == auth.PermListGet {
|
||||
u.GetListIDs = append(u.GetListIDs, p.ID)
|
||||
}
|
||||
if perm == auth.PermListManage {
|
||||
u.ManageListIDs = append(u.ManageListIDs, p.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
users[n] = u
|
||||
}
|
||||
|
||||
return users
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Package events implements a simple event broadcasting mechanism
|
||||
// for usage in broadcasting error messages, postbacks etc. various
|
||||
// channels.
|
||||
package events
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const (
|
||||
TypeError = "error"
|
||||
)
|
||||
|
||||
// Event represents a single event in the system.
|
||||
type Event struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
Data any `json:"data"`
|
||||
Channels []string `json:"-"`
|
||||
}
|
||||
|
||||
type Events struct {
|
||||
subs map[string]chan Event
|
||||
sync.RWMutex
|
||||
}
|
||||
|
||||
// New returns a new instance of Events.
|
||||
func New() *Events {
|
||||
return &Events{
|
||||
subs: make(map[string]chan Event),
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe returns a channel to which the given event `types` are streamed.
|
||||
// id is the unique identifier for the caller. A caller can only register
|
||||
// for subscription once.
|
||||
func (ev *Events) Subscribe(id string) (chan Event, error) {
|
||||
ev.Lock()
|
||||
defer ev.Unlock()
|
||||
|
||||
if ch, ok := ev.subs[id]; ok {
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
ch := make(chan Event, 100)
|
||||
ev.subs[id] = ch
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// Unsubscribe unsubscribes a subscriber (obviously).
|
||||
func (ev *Events) Unsubscribe(id string) {
|
||||
ev.Lock()
|
||||
defer ev.Unlock()
|
||||
delete(ev.subs, id)
|
||||
}
|
||||
|
||||
// Publish publishes an event to all subscribers.
|
||||
func (ev *Events) Publish(e Event) error {
|
||||
ev.Lock()
|
||||
defer ev.Unlock()
|
||||
|
||||
for _, ch := range ev.subs {
|
||||
select {
|
||||
case ch <- e:
|
||||
default:
|
||||
return fmt.Errorf("event queue full for type: %s", e.Type)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// This implements an io.Writer specifically for receiving error messages
|
||||
// mirrored (io.MultiWriter) from error log writing.
|
||||
type wri struct {
|
||||
ev *Events
|
||||
}
|
||||
|
||||
func (w *wri) Write(b []byte) (n int, err error) {
|
||||
// Only broadcast error messages.
|
||||
if !bytes.Contains(b, []byte("error")) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
w.ev.Publish(Event{
|
||||
Type: TypeError,
|
||||
Message: string(b),
|
||||
})
|
||||
|
||||
return len(b), nil
|
||||
}
|
||||
|
||||
func (ev *Events) ErrWriter() io.Writer {
|
||||
return &wri{ev: ev}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
// i18n is a simple package that translates strings using a language map.
|
||||
// It mimics some functionality of the vue-i18n library so that the same JSON
|
||||
// language map may be used in the JS frontend and the Go backend.
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// I18n offers translation functions over a language map.
|
||||
type I18n struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
langMap map[string]string
|
||||
}
|
||||
|
||||
var reParam = regexp.MustCompile(`(?i)\{([a-z0-9-.]+)\}`)
|
||||
|
||||
// New returns an I18n instance.
|
||||
func New(b []byte) (*I18n, error) {
|
||||
var l map[string]string
|
||||
if err := json.Unmarshal(b, &l); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
code, ok := l["_.code"]
|
||||
if !ok {
|
||||
return nil, errors.New("missing _.code field in language file")
|
||||
}
|
||||
|
||||
name, ok := l["_.name"]
|
||||
if !ok {
|
||||
return nil, errors.New("missing _.name field in language file")
|
||||
}
|
||||
|
||||
return &I18n{
|
||||
langMap: l,
|
||||
Code: code,
|
||||
Name: name,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Load loads a JSON language map into the instance overwriting
|
||||
// existing keys that conflict.
|
||||
func (i *I18n) Load(b []byte) error {
|
||||
var l map[string]string
|
||||
if err := json.Unmarshal(b, &l); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for k, v := range l {
|
||||
i.langMap[k] = v
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// JSON returns the languagemap as raw JSON.
|
||||
func (i *I18n) JSON() []byte {
|
||||
b, _ := json.Marshal(i.langMap)
|
||||
return b
|
||||
}
|
||||
|
||||
// T returns the translation for the given key similar to vue i18n's t().
|
||||
func (i *I18n) T(key string) string {
|
||||
s, ok := i.langMap[key]
|
||||
if !ok {
|
||||
return key
|
||||
}
|
||||
|
||||
return i.getSingular(s)
|
||||
}
|
||||
|
||||
// Ts returns the translation for the given key similar to vue i18n's t()
|
||||
// and substitutes the params in the given map in the translated value.
|
||||
// In the language values, the substitutions are represented as: {key}
|
||||
// The params and values are received as a pairs of succeeding strings.
|
||||
// That is, the number of these arguments should be an even number.
|
||||
// eg: Ts("globals.message.notFound",
|
||||
//
|
||||
// "name", "campaigns",
|
||||
// "error", err)
|
||||
func (i *I18n) Ts(key string, params ...string) string {
|
||||
if len(params)%2 != 0 {
|
||||
return key + `: Invalid arguments`
|
||||
}
|
||||
|
||||
s, ok := i.langMap[key]
|
||||
if !ok {
|
||||
return key
|
||||
}
|
||||
|
||||
s = i.getSingular(s)
|
||||
for n := 0; n < len(params); n += 2 {
|
||||
// If there are {params} in the param values, substitute them.
|
||||
val := i.subAllParams(params[n+1])
|
||||
s = strings.ReplaceAll(s, `{`+params[n]+`}`, val)
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// Tc returns the translation for the given key similar to vue i18n's tc().
|
||||
// It expects the language string in the map to be of the form `Singular | Plural` and
|
||||
// returns `Plural` if n > 1, or `Singular` otherwise.
|
||||
func (i *I18n) Tc(key string, n int) string {
|
||||
s, ok := i.langMap[key]
|
||||
if !ok {
|
||||
return key
|
||||
}
|
||||
|
||||
// Plural.
|
||||
if n > 1 {
|
||||
return i.getPlural(s)
|
||||
}
|
||||
|
||||
return i.getSingular(s)
|
||||
}
|
||||
|
||||
// getSingular returns the singular term from the vuei18n pipe separated value.
|
||||
// singular term | plural term
|
||||
func (i *I18n) getSingular(s string) string {
|
||||
if !strings.Contains(s, "|") {
|
||||
return s
|
||||
}
|
||||
|
||||
return strings.TrimSpace(strings.Split(s, "|")[0])
|
||||
}
|
||||
|
||||
// getPlural returns the plural term from the vuei18n pipe separated value.
|
||||
// singular term | plural term
|
||||
func (i *I18n) getPlural(s string) string {
|
||||
if !strings.Contains(s, "|") {
|
||||
return s
|
||||
}
|
||||
|
||||
chunks := strings.Split(s, "|")
|
||||
if len(chunks) == 2 {
|
||||
return strings.TrimSpace(chunks[1])
|
||||
}
|
||||
|
||||
return strings.TrimSpace(chunks[0])
|
||||
}
|
||||
|
||||
// subAllParams recursively resolves and replaces all {params} in a string.
|
||||
func (i *I18n) subAllParams(s string) string {
|
||||
if !strings.Contains(s, `{`) {
|
||||
return s
|
||||
}
|
||||
|
||||
parts := reParam.FindAllStringSubmatch(s, -1)
|
||||
if len(parts) < 1 {
|
||||
return s
|
||||
}
|
||||
|
||||
for _, p := range parts {
|
||||
s = strings.ReplaceAll(s, p[0], i.T(p[1]))
|
||||
}
|
||||
|
||||
return i.subAllParams(s)
|
||||
}
|
||||
@@ -0,0 +1,827 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/textproto"
|
||||
"net/url"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"maps"
|
||||
|
||||
"github.com/Masterminds/sprig/v3"
|
||||
"source.offmarket.win/aleagle/eaglecast/internal/i18n"
|
||||
"source.offmarket.win/aleagle/eaglecast/internal/notifs"
|
||||
"source.offmarket.win/aleagle/eaglecast/models"
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
// attribInlineEmbed is the HTML attrib used to mark <img> tags whose source
|
||||
// should be embedded as a multipart cid inline attachment.
|
||||
const attribInlineEmbed = "data-embed"
|
||||
|
||||
var (
|
||||
reInlineImage = regexp.MustCompile(`(?is)<img\b[^>]*\b` + attribInlineEmbed + `\b[^>]*>`)
|
||||
reImgSrc = regexp.MustCompile(`(?is)(\s)src\s*=\s*(?:"([^"]*)"|'([^']*)')`)
|
||||
)
|
||||
|
||||
const (
|
||||
// BaseTPL is the name of the base template.
|
||||
BaseTPL = "base"
|
||||
|
||||
// ContentTpl is the name of the compiled message.
|
||||
ContentTpl = "content"
|
||||
|
||||
dummyUUID = "00000000-0000-0000-0000-000000000000"
|
||||
)
|
||||
|
||||
// Store represents a data backend, such as a database,
|
||||
// that provides subscriber and campaign records.
|
||||
type Store interface {
|
||||
NextCampaigns(currentIDs []int64, sentCounts []int64) ([]*models.Campaign, error)
|
||||
NextSubscribers(campID, limit int) ([]models.Subscriber, error)
|
||||
GetCampaign(campID int) (*models.Campaign, error)
|
||||
GetAttachment(mediaID int) (models.Attachment, error)
|
||||
GetInlineAttachmentByFilename(filename string) (models.Attachment, string, error)
|
||||
UpdateCampaignStatus(campID int, status string) error
|
||||
UpdateCampaignCounts(campID int, toSend int, sent int, lastSubID int) error
|
||||
CreateLink(url string) (string, error)
|
||||
BlocklistSubscriber(id int64) error
|
||||
DeleteSubscriber(id int64) error
|
||||
}
|
||||
|
||||
// Messenger is an interface for a generic messaging backend,
|
||||
// for instance, e-mail, SMS etc.
|
||||
type Messenger interface {
|
||||
Name() string
|
||||
Push(models.Message) error
|
||||
Flush() error
|
||||
Close() error
|
||||
}
|
||||
|
||||
// CampStats contains campaign stats like per minute send rate.
|
||||
type CampStats struct {
|
||||
SendRate int
|
||||
}
|
||||
|
||||
// Manager handles the scheduling, processing, and queuing of campaigns
|
||||
// and message pushes.
|
||||
type Manager struct {
|
||||
cfg Config
|
||||
store Store
|
||||
i18n *i18n.I18n
|
||||
messengers map[string]Messenger
|
||||
fnNotify func(subject string, data any) error
|
||||
log *log.Logger
|
||||
|
||||
// Campaigns that are currently running.
|
||||
pipes map[int]*pipe
|
||||
pipesMut sync.RWMutex
|
||||
|
||||
tpls map[int]*models.Template
|
||||
tplsMut sync.RWMutex
|
||||
|
||||
// Links generated using Track() are cached here so as to not query
|
||||
// the database for the link UUID for every message sent. This has to
|
||||
// be locked as it may be used externally when previewing campaigns.
|
||||
links map[string]string
|
||||
linksMut sync.RWMutex
|
||||
|
||||
nextPipes chan *pipe
|
||||
campMsgQ chan CampaignMessage
|
||||
msgQ chan models.Message
|
||||
|
||||
// Sliding window keeps track of the total number of messages sent in a period
|
||||
// and on reaching the specified limit, waits until the window is over before
|
||||
// sending further messages.
|
||||
slidingCount int
|
||||
slidingStart time.Time
|
||||
|
||||
tplFuncs template.FuncMap
|
||||
}
|
||||
|
||||
// CampaignMessage represents an instance of campaign message to be pushed out,
|
||||
// specific to a subscriber, via the campaign's messenger.
|
||||
type CampaignMessage struct {
|
||||
Campaign *models.Campaign
|
||||
Subscriber models.Subscriber
|
||||
|
||||
from string
|
||||
to string
|
||||
subject string
|
||||
body []byte
|
||||
altBody []byte
|
||||
unsubURL string
|
||||
headers models.Headers
|
||||
|
||||
pipe *pipe
|
||||
}
|
||||
|
||||
// Config has parameters for configuring the manager.
|
||||
type Config struct {
|
||||
// Number of subscribers to pull from the DB in a single iteration.
|
||||
BatchSize int
|
||||
Concurrency int
|
||||
MessageRate int
|
||||
MaxSendErrors int
|
||||
SlidingWindow bool
|
||||
SlidingWindowDuration time.Duration
|
||||
SlidingWindowRate int
|
||||
RequeueOnError bool
|
||||
FromEmail string
|
||||
IndividualTracking bool
|
||||
DisableTracking bool
|
||||
LinkTrackURL string
|
||||
UnsubURL string
|
||||
OptinURL string
|
||||
MessageURL string
|
||||
ViewTrackURL string
|
||||
ArchiveURL string
|
||||
RootURL string
|
||||
UnsubHeader bool
|
||||
|
||||
// Interval to scan the DB for active campaign checkpoints.
|
||||
ScanInterval time.Duration
|
||||
|
||||
// ScanCampaigns indicates whether this instance of manager will scan the DB
|
||||
// for active campaigns and process them.
|
||||
// This can be used to run multiple instances of eaglecast
|
||||
// (exposed to the internet, private etc.) where only one does campaign
|
||||
// processing while the others handle other kinds of traffic.
|
||||
ScanCampaigns bool
|
||||
}
|
||||
|
||||
var pushTimeout = time.Second * 3
|
||||
|
||||
// New returns a new instance of Mailer.
|
||||
func New(cfg Config, store Store, i *i18n.I18n, l *log.Logger) *Manager {
|
||||
if cfg.BatchSize < 1 {
|
||||
cfg.BatchSize = 1000
|
||||
}
|
||||
if cfg.Concurrency < 1 {
|
||||
cfg.Concurrency = 1
|
||||
}
|
||||
if cfg.MessageRate < 1 {
|
||||
cfg.MessageRate = 1
|
||||
}
|
||||
|
||||
m := &Manager{
|
||||
cfg: cfg,
|
||||
store: store,
|
||||
i18n: i,
|
||||
fnNotify: func(subject string, data any) error {
|
||||
return notifs.NotifySystem(subject, notifs.TplCampaignStatus, data, nil)
|
||||
},
|
||||
log: l,
|
||||
messengers: make(map[string]Messenger),
|
||||
pipes: make(map[int]*pipe),
|
||||
tpls: make(map[int]*models.Template),
|
||||
links: make(map[string]string),
|
||||
nextPipes: make(chan *pipe, 1000),
|
||||
campMsgQ: make(chan CampaignMessage, cfg.Concurrency*cfg.MessageRate*2),
|
||||
msgQ: make(chan models.Message, cfg.Concurrency*cfg.MessageRate*2),
|
||||
slidingStart: time.Now(),
|
||||
}
|
||||
m.tplFuncs = m.makeGnericFuncMap()
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// AddMessenger adds a Messenger messaging backend to the manager.
|
||||
func (m *Manager) AddMessenger(msg Messenger) error {
|
||||
id := msg.Name()
|
||||
if _, ok := m.messengers[id]; ok {
|
||||
return fmt.Errorf("messenger '%s' is already loaded", id)
|
||||
}
|
||||
m.messengers[id] = msg
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PushMessage pushes an arbitrary non-campaign Message to be sent out by the workers.
|
||||
// It times out if the queue is busy.
|
||||
func (m *Manager) PushMessage(msg models.Message) error {
|
||||
t := time.NewTicker(pushTimeout)
|
||||
defer t.Stop()
|
||||
|
||||
select {
|
||||
case m.msgQ <- msg:
|
||||
case <-t.C:
|
||||
m.log.Printf("message push timed out: '%s'", msg.Subject)
|
||||
return errors.New("message push timed out")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PushCampaignMessage pushes a campaign messages into a queue to be sent out by the workers.
|
||||
// It times out if the queue is busy.
|
||||
func (m *Manager) PushCampaignMessage(msg CampaignMessage) error {
|
||||
t := time.NewTicker(pushTimeout)
|
||||
defer t.Stop()
|
||||
|
||||
// Load any media/attachments.
|
||||
if err := m.attachMedia(msg.Campaign); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
select {
|
||||
case m.campMsgQ <- msg:
|
||||
case <-t.C:
|
||||
m.log.Printf("message push timed out: '%s'", msg.Subject())
|
||||
return errors.New("message push timed out")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasMessenger checks if a given messenger is registered.
|
||||
func (m *Manager) HasMessenger(id string) bool {
|
||||
_, ok := m.messengers[id]
|
||||
|
||||
return ok
|
||||
}
|
||||
|
||||
// HasRunningCampaigns checks if there are any active campaigns.
|
||||
func (m *Manager) HasRunningCampaigns() bool {
|
||||
m.pipesMut.Lock()
|
||||
defer m.pipesMut.Unlock()
|
||||
|
||||
return len(m.pipes) > 0
|
||||
}
|
||||
|
||||
// GetCampaignStats returns campaign statistics.
|
||||
func (m *Manager) GetCampaignStats(id int) CampStats {
|
||||
n := 0
|
||||
|
||||
m.pipesMut.Lock()
|
||||
if c, ok := m.pipes[id]; ok {
|
||||
n = int(c.rate.Rate())
|
||||
}
|
||||
m.pipesMut.Unlock()
|
||||
|
||||
return CampStats{SendRate: n}
|
||||
}
|
||||
|
||||
// Run is a blocking function (that should be invoked as a goroutine)
|
||||
// that scans the data source at regular intervals for pending campaigns,
|
||||
// and queues them for processing. The process queue fetches batches of
|
||||
// subscribers and pushes messages to them for each queued campaign
|
||||
// until all subscribers are exhausted, at which point, a campaign is marked
|
||||
// as "finished".
|
||||
func (m *Manager) Run() {
|
||||
if m.cfg.ScanCampaigns {
|
||||
// Periodically scan campaigns and push running campaigns to nextPipes
|
||||
// to fetch subscribers from the campaign.
|
||||
go m.scanCampaigns(m.cfg.ScanInterval)
|
||||
}
|
||||
|
||||
// Spawn N message workers.
|
||||
for i := 0; i < m.cfg.Concurrency; i++ {
|
||||
go m.worker()
|
||||
}
|
||||
|
||||
// Indefinitely wait on the pipe queue to fetch the next set of subscribers
|
||||
// for any active campaigns.
|
||||
for p := range m.nextPipes {
|
||||
has, err := p.NextSubscribers()
|
||||
if err != nil {
|
||||
m.log.Printf("error processing campaign batch (%s): %v", p.camp.Name, err)
|
||||
|
||||
// If the batch fails, stop the pipe and release it so that it doesn't hang forever.
|
||||
// The cleanup() records the state in DB and scanCampaigns() picks it up at a later point.
|
||||
p.Stop(false)
|
||||
p.wg.Done()
|
||||
continue
|
||||
}
|
||||
|
||||
if has {
|
||||
// There are more subscribers to fetch. Queue again.
|
||||
select {
|
||||
case m.nextPipes <- p:
|
||||
default:
|
||||
// If the queue is full for any reason, stop the pipe and release it.
|
||||
// The cleanup() records the state in DB and scanCampaigns() picks it up
|
||||
// at a later point.
|
||||
p.Stop(false)
|
||||
p.wg.Done()
|
||||
}
|
||||
} else {
|
||||
// The pipe is created with a +1 on the waitgroup pseudo counter
|
||||
// so that it immediately waits. Subsequently, every message created
|
||||
// is incremented in the counter in pipe.newMessage(), and when it's'
|
||||
// processed (or ignored when a campaign is paused or cancelled),
|
||||
// the count is's reduced in worker().
|
||||
//
|
||||
// This marks down the original non-message +1, causing the waitgroup
|
||||
// to be released and the pipe to end, triggering the pg.Wait()
|
||||
// in newPipe() that calls pipe.cleanup().
|
||||
p.wg.Done()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CacheTpl caches a template for ad-hoc use. This is currently only used by tx templates.
|
||||
func (m *Manager) CacheTpl(id int, tpl *models.Template) {
|
||||
if body, atts := m.ApplyInlineImages(tpl.Body); len(atts) > 0 {
|
||||
tpl.Body = body
|
||||
tpl.Attachments = atts
|
||||
if err := tpl.Compile(m.GenericTemplateFuncs()); err != nil {
|
||||
m.log.Printf("error recompiling tx template %d after inline image: %v", id, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
m.tplsMut.Lock()
|
||||
m.tpls[id] = tpl
|
||||
m.tplsMut.Unlock()
|
||||
}
|
||||
|
||||
// DeleteTpl deletes a cached template.
|
||||
func (m *Manager) DeleteTpl(id int) {
|
||||
m.tplsMut.Lock()
|
||||
delete(m.tpls, id)
|
||||
m.tplsMut.Unlock()
|
||||
}
|
||||
|
||||
// GetTpl returns a cached template.
|
||||
func (m *Manager) GetTpl(id int) (*models.Template, error) {
|
||||
m.tplsMut.RLock()
|
||||
tpl, ok := m.tpls[id]
|
||||
m.tplsMut.RUnlock()
|
||||
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("template %d not found", id)
|
||||
}
|
||||
|
||||
return tpl, nil
|
||||
}
|
||||
|
||||
// TemplateFuncs returns the template functions to be applied into
|
||||
// compiled campaign templates.
|
||||
func (m *Manager) TemplateFuncs(c *models.Campaign) template.FuncMap {
|
||||
f := template.FuncMap{
|
||||
"TrackLink": func(url string, msg *CampaignMessage) string {
|
||||
if m.cfg.DisableTracking {
|
||||
return url
|
||||
}
|
||||
|
||||
subUUID := msg.Subscriber.UUID
|
||||
if !m.cfg.IndividualTracking {
|
||||
subUUID = dummyUUID
|
||||
}
|
||||
|
||||
return m.trackLink(url, msg.Campaign.UUID, subUUID)
|
||||
},
|
||||
"TrackView": func(msg *CampaignMessage) template.HTML {
|
||||
if m.cfg.DisableTracking {
|
||||
return template.HTML("")
|
||||
}
|
||||
|
||||
subUUID := msg.Subscriber.UUID
|
||||
if !m.cfg.IndividualTracking {
|
||||
subUUID = dummyUUID
|
||||
}
|
||||
|
||||
return template.HTML(fmt.Sprintf(`<img src="%s" alt="" />`,
|
||||
fmt.Sprintf(m.cfg.ViewTrackURL, msg.Campaign.UUID, subUUID)))
|
||||
},
|
||||
"UnsubscribeURL": func(msg *CampaignMessage) string {
|
||||
return msg.unsubURL
|
||||
},
|
||||
"ManageURL": func(msg *CampaignMessage) string {
|
||||
return msg.unsubURL + "?manage=true"
|
||||
},
|
||||
"OptinURL": func(msg *CampaignMessage) string {
|
||||
// Add list IDs.
|
||||
// TODO: Show private lists list on optin e-mail
|
||||
return fmt.Sprintf(m.cfg.OptinURL, msg.Subscriber.UUID, "")
|
||||
},
|
||||
"MessageURL": func(msg *CampaignMessage) string {
|
||||
return fmt.Sprintf(m.cfg.MessageURL, c.UUID, msg.Subscriber.UUID)
|
||||
},
|
||||
"ArchiveURL": func() string {
|
||||
return m.cfg.ArchiveURL
|
||||
},
|
||||
"RootURL": func() string {
|
||||
return m.cfg.RootURL
|
||||
},
|
||||
}
|
||||
|
||||
maps.Copy(f, m.tplFuncs)
|
||||
|
||||
return f
|
||||
}
|
||||
|
||||
func (m *Manager) GenericTemplateFuncs() template.FuncMap {
|
||||
return m.tplFuncs
|
||||
}
|
||||
|
||||
// StopCampaign marks a running campaign as stopped so that all its queued messages are ignored.
|
||||
func (m *Manager) StopCampaign(id int) {
|
||||
m.pipesMut.RLock()
|
||||
if p, ok := m.pipes[id]; ok {
|
||||
p.Stop(false)
|
||||
}
|
||||
m.pipesMut.RUnlock()
|
||||
}
|
||||
|
||||
// Close closes and exits the campaign manager.
|
||||
func (m *Manager) Close() {
|
||||
close(m.nextPipes)
|
||||
close(m.msgQ)
|
||||
}
|
||||
|
||||
// scanCampaigns is a blocking function that periodically scans the data source
|
||||
// for campaigns to process and dispatches them to the manager. It feeds campaigns
|
||||
// into nextPipes.
|
||||
func (m *Manager) scanCampaigns(tick time.Duration) {
|
||||
t := time.NewTicker(tick)
|
||||
defer t.Stop()
|
||||
|
||||
// Periodically scan the data source for campaigns to process.
|
||||
for range t.C {
|
||||
ids, counts := m.getCurrentCampaigns()
|
||||
campaigns, err := m.store.NextCampaigns(ids, counts)
|
||||
if err != nil {
|
||||
m.log.Printf("error fetching campaigns: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, c := range campaigns {
|
||||
// Create a new pipe that'll handle this campaign's states.
|
||||
p, err := m.newPipe(c)
|
||||
if err != nil {
|
||||
m.log.Printf("error processing campaign (%s): %v", c.Name, err)
|
||||
continue
|
||||
}
|
||||
m.log.Printf("start processing campaign (%s)", c.Name)
|
||||
|
||||
// If subscriber processing is busy, move on. Blocking and waiting
|
||||
// can end up in a race condition where the waiting campaign's
|
||||
// state in the data source has changed.
|
||||
select {
|
||||
case m.nextPipes <- p:
|
||||
default:
|
||||
// If the queue is full for any reason, stop the pipe and release it.
|
||||
// The cleanup() records the state in DB and scanCampaigns() picks it up
|
||||
// at a later point.
|
||||
p.Stop(false)
|
||||
p.wg.Done()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// worker is a blocking function that perpetually listents to events (message) on different
|
||||
// queues and processes them.
|
||||
func (m *Manager) worker() {
|
||||
// Counter to keep track of the message / sec rate limit.
|
||||
numMsg := 0
|
||||
for {
|
||||
select {
|
||||
// Campaign message.
|
||||
case msg, ok := <-m.campMsgQ:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// If the campaign has ended or stopped, ignore the message.
|
||||
if msg.pipe != nil && msg.pipe.stopped.Load() {
|
||||
// Reduce the message counter on the pipe.
|
||||
msg.pipe.wg.Done()
|
||||
continue
|
||||
}
|
||||
|
||||
// Pause on hitting the message rate.
|
||||
if numMsg >= m.cfg.MessageRate {
|
||||
time.Sleep(time.Second)
|
||||
numMsg = 0
|
||||
}
|
||||
numMsg++
|
||||
|
||||
// Outgoing message.
|
||||
out := models.Message{
|
||||
From: msg.from,
|
||||
To: []string{msg.to},
|
||||
Subject: msg.subject,
|
||||
ContentType: msg.Campaign.ContentType,
|
||||
Body: msg.body,
|
||||
AltBody: msg.altBody,
|
||||
Subscriber: msg.Subscriber,
|
||||
Campaign: msg.Campaign,
|
||||
Attachments: msg.Campaign.Attachments,
|
||||
}
|
||||
|
||||
h := textproto.MIMEHeader{}
|
||||
h.Set(models.EmailHeaderCampaignUUID, msg.Campaign.UUID)
|
||||
h.Set(models.EmailHeaderSubscriberUUID, msg.Subscriber.UUID)
|
||||
|
||||
// Attach List-Unsubscribe headers?
|
||||
if m.cfg.UnsubHeader {
|
||||
h.Set("List-Unsubscribe-Post", "List-Unsubscribe=One-Click")
|
||||
h.Set("List-Unsubscribe", `<`+msg.unsubURL+`>`)
|
||||
}
|
||||
|
||||
// Attach any custom headers.
|
||||
for _, set := range msg.headers {
|
||||
for hdr, val := range set {
|
||||
h.Add(hdr, val)
|
||||
}
|
||||
}
|
||||
|
||||
// Set the headers.
|
||||
out.Headers = h
|
||||
|
||||
// Push the message to the messenger.
|
||||
err := m.messengers[msg.Campaign.Messenger].Push(out)
|
||||
if err != nil {
|
||||
m.log.Printf("error sending message in campaign %s: subscriber %d: %v", msg.Campaign.Name, msg.Subscriber.ID, err)
|
||||
}
|
||||
|
||||
// Increment the send rate or the error counter if there was an error.
|
||||
if msg.pipe != nil {
|
||||
// Mark the message as done.
|
||||
msg.pipe.wg.Done()
|
||||
|
||||
if err != nil {
|
||||
// Call the error callback, which keeps track of the error count
|
||||
// and stops the campaign if the error count exceeds the threshold.
|
||||
msg.pipe.OnError()
|
||||
} else {
|
||||
id := uint64(msg.Subscriber.ID)
|
||||
if id > msg.pipe.lastID.Load() {
|
||||
msg.pipe.lastID.Store(uint64(msg.Subscriber.ID))
|
||||
}
|
||||
msg.pipe.rate.Incr(1)
|
||||
msg.pipe.sent.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Arbitrary message.
|
||||
case msg, ok := <-m.msgQ:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Push the message to the messenger.
|
||||
if err := m.messengers[msg.Messenger].Push(msg); err != nil {
|
||||
m.log.Printf("error sending message '%s': %v", msg.Subject, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getCurrentCampaigns returns the IDs of campaigns currently being processed
|
||||
// and their sent counts.
|
||||
func (m *Manager) getCurrentCampaigns() ([]int64, []int64) {
|
||||
// Needs to return an empty slice in case there are no campaigns.
|
||||
m.pipesMut.RLock()
|
||||
defer m.pipesMut.RUnlock()
|
||||
|
||||
var (
|
||||
ids = make([]int64, 0, len(m.pipes))
|
||||
counts = make([]int64, 0, len(m.pipes))
|
||||
)
|
||||
for _, p := range m.pipes {
|
||||
ids = append(ids, int64(p.camp.ID))
|
||||
|
||||
// Get the sent counts for campaigns and reset them to 0
|
||||
// as in the database, they're stored cumulatively (sent += $newSent).
|
||||
counts = append(counts, p.sent.Load())
|
||||
p.sent.Store(0)
|
||||
}
|
||||
|
||||
return ids, counts
|
||||
}
|
||||
|
||||
// trackLink register a URL and return its UUID to be used in message templates
|
||||
// for tracking links.
|
||||
func (m *Manager) trackLink(url, campUUID, subUUID string) string {
|
||||
if m.cfg.DisableTracking {
|
||||
return url
|
||||
}
|
||||
|
||||
url = strings.ReplaceAll(url, "&", "&")
|
||||
|
||||
m.linksMut.RLock()
|
||||
if uu, ok := m.links[url]; ok {
|
||||
m.linksMut.RUnlock()
|
||||
return fmt.Sprintf(m.cfg.LinkTrackURL, uu, campUUID, subUUID)
|
||||
}
|
||||
m.linksMut.RUnlock()
|
||||
|
||||
// Register link.
|
||||
uu, err := m.store.CreateLink(url)
|
||||
if err != nil {
|
||||
m.log.Printf("error registering tracking for link '%s': %v", url, err)
|
||||
|
||||
// If the registration fails, fail over to the original URL.
|
||||
return url
|
||||
}
|
||||
|
||||
m.linksMut.Lock()
|
||||
m.links[url] = uu
|
||||
m.linksMut.Unlock()
|
||||
|
||||
return fmt.Sprintf(m.cfg.LinkTrackURL, uu, campUUID, subUUID)
|
||||
}
|
||||
|
||||
// sendNotif sends a notification to registered admin e-mails.
|
||||
func (m *Manager) sendNotif(c *models.Campaign, status, reason string) error {
|
||||
var (
|
||||
subject = fmt.Sprintf("%s: %s", cases.Title(language.Und).String(status), c.Name)
|
||||
data = map[string]any{
|
||||
"ID": c.ID,
|
||||
"Name": c.Name,
|
||||
"Status": status,
|
||||
"Sent": c.Sent,
|
||||
"ToSend": c.ToSend,
|
||||
"Reason": reason,
|
||||
}
|
||||
)
|
||||
|
||||
return m.fnNotify(subject, data)
|
||||
}
|
||||
|
||||
// makeGnericFuncMap returns a generic template func map with custom template
|
||||
// functions and sprig template functions.
|
||||
func (m *Manager) makeGnericFuncMap() template.FuncMap {
|
||||
funcs := template.FuncMap{
|
||||
"Date": func(layout string) string {
|
||||
if layout == "" {
|
||||
layout = time.ANSIC
|
||||
}
|
||||
return time.Now().Format(layout)
|
||||
},
|
||||
"L": func() *i18n.I18n {
|
||||
return m.i18n
|
||||
},
|
||||
"Safe": func(safeHTML string) template.HTML {
|
||||
return template.HTML(safeHTML)
|
||||
},
|
||||
}
|
||||
|
||||
// Copy spring functions.
|
||||
sprigFuncs := sprig.GenericFuncMap()
|
||||
delete(sprigFuncs, "env")
|
||||
delete(sprigFuncs, "expandenv")
|
||||
delete(sprigFuncs, "getHostByName")
|
||||
|
||||
maps.Copy(funcs, sprigFuncs)
|
||||
|
||||
return funcs
|
||||
}
|
||||
|
||||
// attachMedia loads any media/attachments from the media store and attaches
|
||||
// the byte blobs to the campaign. Inline attachments are skipped as they're
|
||||
// loaded earlier by LoadInlineImages().
|
||||
func (m *Manager) attachMedia(c *models.Campaign) error {
|
||||
// Already loaded if any non-inline attachment is present.
|
||||
for _, a := range c.Attachments {
|
||||
if !a.IsInline {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
for _, mid := range []int64(c.MediaIDs) {
|
||||
a, err := m.store.GetAttachment(int(mid))
|
||||
if err != nil {
|
||||
return fmt.Errorf("error fetching attachment %d on campaign %s: %v", mid, c.Name, err)
|
||||
}
|
||||
c.Attachments = append(c.Attachments, a)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadInlineImages resolves any <img ... data-embed ...> tags in the campaign
|
||||
// body and template body one time before CompileTemplate.
|
||||
func (m *Manager) LoadInlineImages(c *models.Campaign) error {
|
||||
if c.ContentType == models.CampaignContentTypePlain {
|
||||
return nil
|
||||
}
|
||||
|
||||
cidCache := make(map[string]string)
|
||||
body, atts := m.applyInlineImages(c.Body, cidCache)
|
||||
c.Body = body
|
||||
|
||||
tplBody, tplAtts := m.applyInlineImages(c.TemplateBody, cidCache)
|
||||
c.TemplateBody = tplBody
|
||||
atts = append(atts, tplAtts...)
|
||||
|
||||
c.Attachments = append(c.Attachments, atts...)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApplyInlineImages scans body for <img ... data-embed ...> tags, resolves
|
||||
// each unique src filename to a media item, attaches it as an inline part, and
|
||||
// rewrites the matched img src to cid.
|
||||
func (m *Manager) ApplyInlineImages(body string) (string, []models.Attachment) {
|
||||
return m.applyInlineImages(body, make(map[string]string))
|
||||
}
|
||||
|
||||
func (m *Manager) applyInlineImages(body string, cache map[string]string) (string, []models.Attachment) {
|
||||
if !strings.Contains(body, attribInlineEmbed) {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
var atts []models.Attachment
|
||||
out := reInlineImage.ReplaceAllStringFunc(body, func(tag string) string {
|
||||
src := extractSrc(tag)
|
||||
if src == "" || strings.HasPrefix(strings.ToLower(src), "cid:") {
|
||||
return tag
|
||||
}
|
||||
|
||||
fname := filenameFromSrc(src)
|
||||
if fname == "" {
|
||||
return tag
|
||||
}
|
||||
|
||||
cid, ok := cache[src]
|
||||
if !ok {
|
||||
if a, c, err := m.store.GetInlineAttachmentByFilename(fname); err == nil {
|
||||
atts = append(atts, a)
|
||||
cid = c
|
||||
} else {
|
||||
m.log.Printf("inline image %q not embedded: %v", src, err)
|
||||
}
|
||||
cache[src] = cid
|
||||
}
|
||||
if cid == "" {
|
||||
return tag
|
||||
}
|
||||
return reImgSrc.ReplaceAllString(tag, `${1}src="cid:`+cid+`"`)
|
||||
})
|
||||
|
||||
return out, atts
|
||||
}
|
||||
|
||||
// MakeContentID returns a standard `Content-ID` value (without the angle brackets).
|
||||
func MakeContentID(key string) string {
|
||||
sum := sha1.Sum([]byte(key))
|
||||
return hex.EncodeToString(sum[:8]) + "@email"
|
||||
}
|
||||
|
||||
// filenameFromSrc extracts a media filename from an <img src> value (ignoring
|
||||
// any URLs and just capturing the filename.
|
||||
func filenameFromSrc(src string) string {
|
||||
if src == "" {
|
||||
return ""
|
||||
}
|
||||
p := src
|
||||
if u, err := url.Parse(src); err == nil && u.Path != "" {
|
||||
p = u.Path
|
||||
}
|
||||
return path.Base(p)
|
||||
}
|
||||
|
||||
// MakeAttachmentHeader returns a textproto.MIMEHeader for an email
|
||||
// attachment. Default encoding is base64 and contentType is application/octet-stream.
|
||||
func MakeAttachmentHeader(filename, encoding, contentType string) textproto.MIMEHeader {
|
||||
return makeAttachmentHeader("attachment", filename, encoding, contentType, "")
|
||||
}
|
||||
|
||||
// MakeInlineAttachmentHeader returns a textproto.MIMEHeader for an inline
|
||||
// image attachment referenced from the HTML body via a cid URL.
|
||||
func MakeInlineAttachmentHeader(filename, encoding, contentType, contentID string) textproto.MIMEHeader {
|
||||
return makeAttachmentHeader("inline", filename, encoding, contentType, contentID)
|
||||
}
|
||||
|
||||
func makeAttachmentHeader(disposition, filename, encoding, contentType, contentID string) textproto.MIMEHeader {
|
||||
if encoding == "" {
|
||||
encoding = "base64"
|
||||
}
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
|
||||
h := textproto.MIMEHeader{}
|
||||
h.Set("Content-Disposition", disposition+"; filename="+filename)
|
||||
h.Set("Content-Type", fmt.Sprintf("%s; name=\""+filename+"\"", contentType))
|
||||
h.Set("Content-Transfer-Encoding", encoding)
|
||||
if contentID != "" {
|
||||
h.Set("Content-ID", "<"+contentID+">")
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func extractSrc(tag string) string {
|
||||
m := reImgSrc.FindStringSubmatch(tag)
|
||||
if len(m) == 0 {
|
||||
return ""
|
||||
}
|
||||
if m[2] != "" {
|
||||
return m[2]
|
||||
}
|
||||
return m[3]
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
|
||||
"source.offmarket.win/aleagle/eaglecast/models"
|
||||
)
|
||||
|
||||
// NewCampaignMessage creates and returns a CampaignMessage that is made available
|
||||
// to message templates while they're compiled. It represents a message from
|
||||
// a campaign that's bound to a single Subscriber.
|
||||
func (m *Manager) NewCampaignMessage(c *models.Campaign, s models.Subscriber) (CampaignMessage, error) {
|
||||
msg := CampaignMessage{
|
||||
Campaign: c,
|
||||
Subscriber: s,
|
||||
|
||||
subject: c.Subject,
|
||||
from: c.FromEmail,
|
||||
to: s.Email,
|
||||
unsubURL: fmt.Sprintf(m.cfg.UnsubURL, c.UUID, s.UUID),
|
||||
}
|
||||
|
||||
if err := msg.render(); err != nil {
|
||||
return msg, err
|
||||
}
|
||||
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// render takes a Message, executes its pre-compiled Campaign.Tpl
|
||||
// and applies the resultant bytes to Message.body to be used in messages.
|
||||
func (m *CampaignMessage) render() error {
|
||||
out := bytes.Buffer{}
|
||||
|
||||
// Render the subject if it's a template.
|
||||
if m.Campaign.SubjectTpl != nil {
|
||||
if err := m.Campaign.SubjectTpl.ExecuteTemplate(&out, models.ContentTpl, m); err != nil {
|
||||
return err
|
||||
}
|
||||
m.subject = out.String()
|
||||
out.Reset()
|
||||
}
|
||||
|
||||
// Compile the main template.
|
||||
if err := m.Campaign.Tpl.ExecuteTemplate(&out, models.BaseTpl, m); err != nil {
|
||||
return err
|
||||
}
|
||||
m.body = out.Bytes()
|
||||
|
||||
// Is there an alt body?
|
||||
if m.Campaign.ContentType != models.CampaignContentTypePlain && m.Campaign.AltBody.Valid {
|
||||
if m.Campaign.AltBodyTpl != nil {
|
||||
b := bytes.Buffer{}
|
||||
if err := m.Campaign.AltBodyTpl.ExecuteTemplate(&b, models.ContentTpl, m); err != nil {
|
||||
return err
|
||||
}
|
||||
m.altBody = b.Bytes()
|
||||
} else {
|
||||
m.altBody = []byte(m.Campaign.AltBody.String)
|
||||
}
|
||||
}
|
||||
|
||||
// If there are templated headers, compile them.
|
||||
if m.Campaign.HeaderTpls == nil {
|
||||
m.headers = m.Campaign.Headers
|
||||
} else {
|
||||
hdrOut := bytes.Buffer{}
|
||||
m.headers = make(models.Headers, len(m.Campaign.Headers))
|
||||
for i, set := range m.Campaign.Headers {
|
||||
m.headers[i] = make(map[string]string, len(set))
|
||||
for hdr, val := range set {
|
||||
tpl := m.Campaign.HeaderTpls[i][hdr]
|
||||
if tpl == nil {
|
||||
m.headers[i][hdr] = val
|
||||
continue
|
||||
}
|
||||
hdrOut.Reset()
|
||||
if err := tpl.ExecuteTemplate(&hdrOut, models.ContentTpl, m); err != nil {
|
||||
return fmt.Errorf("error rendering header %q: %v", hdr, err)
|
||||
}
|
||||
m.headers[i][hdr] = hdrOut.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Subject returns a copy of the message subject
|
||||
func (m *CampaignMessage) Subject() string {
|
||||
return m.subject
|
||||
}
|
||||
|
||||
// Body returns a copy of the message body.
|
||||
func (m *CampaignMessage) Body() []byte {
|
||||
out := make([]byte, len(m.body))
|
||||
copy(out, m.body)
|
||||
return out
|
||||
}
|
||||
|
||||
// AltBody returns a copy of the message's alt body.
|
||||
func (m *CampaignMessage) AltBody() []byte {
|
||||
out := make([]byte, len(m.altBody))
|
||||
copy(out, m.altBody)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"source.offmarket.win/aleagle/eaglecast/models"
|
||||
"github.com/paulbellamy/ratecounter"
|
||||
)
|
||||
|
||||
type pipe struct {
|
||||
camp *models.Campaign
|
||||
rate *ratecounter.RateCounter
|
||||
wg *sync.WaitGroup
|
||||
sent atomic.Int64
|
||||
lastID atomic.Uint64
|
||||
errors atomic.Uint64
|
||||
stopped atomic.Bool
|
||||
withErrors atomic.Bool
|
||||
|
||||
m *Manager
|
||||
}
|
||||
|
||||
// newPipe adds a campaign to the process queue.
|
||||
func (m *Manager) newPipe(c *models.Campaign) (*pipe, error) {
|
||||
// Validate messenger.
|
||||
if _, ok := m.messengers[c.Messenger]; !ok {
|
||||
m.store.UpdateCampaignStatus(c.ID, models.CampaignStatusCancelled)
|
||||
return nil, fmt.Errorf("unknown messenger %s on campaign %s", c.Messenger, c.Name)
|
||||
}
|
||||
|
||||
// Resolve any inline images before compiling the template.
|
||||
if err := m.LoadInlineImages(c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Load the template.
|
||||
if err := c.CompileTemplate(m.TemplateFuncs(c)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Load any media/attachments.
|
||||
if err := m.attachMedia(c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add the campaign to the active map.
|
||||
p := &pipe{
|
||||
camp: c,
|
||||
rate: ratecounter.NewRateCounter(time.Minute),
|
||||
wg: &sync.WaitGroup{},
|
||||
m: m,
|
||||
}
|
||||
|
||||
// Increment the waitgroup so that Wait() blocks immediately. This is necessary
|
||||
// as a campaign pipe is created first and subscribers/messages under it are
|
||||
// fetched asynchronolusly later. The messages each add to the wg and that
|
||||
// count is used to determine the exhaustion/completion of all messages.
|
||||
p.wg.Add(1)
|
||||
|
||||
go func() {
|
||||
// Wait for all the messages in the campaign to be processed
|
||||
// (successfully or skipped after errors or cancellation).
|
||||
p.wg.Wait()
|
||||
|
||||
p.cleanup()
|
||||
}()
|
||||
|
||||
m.pipesMut.Lock()
|
||||
m.pipes[c.ID] = p
|
||||
m.pipesMut.Unlock()
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// NextSubscribers processes the next batch of subscribers in a given campaign.
|
||||
// It returns a bool indicating whether any subscribers were processed
|
||||
// in the current batch or not. A false indicates that all subscribers
|
||||
// have been processed, or that a campaign has been paused or cancelled.
|
||||
func (p *pipe) NextSubscribers() (bool, error) {
|
||||
// Fetch the next batch of subscribers from a 'running' campaign.
|
||||
subs, err := p.m.store.NextSubscribers(p.camp.ID, p.m.cfg.BatchSize)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("error fetching campaign subscribers (%s): %v", p.camp.Name, err)
|
||||
}
|
||||
|
||||
// There are no subscribers from the query. Either all subscribers on the campaign
|
||||
// have been processed, or the campaign has changed from 'running' to 'paused' or 'cancelled'.
|
||||
if len(subs) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Is there a sliding window limit configured?
|
||||
hasSliding := p.m.cfg.SlidingWindow &&
|
||||
p.m.cfg.SlidingWindowRate > 0 &&
|
||||
p.m.cfg.SlidingWindowDuration.Seconds() > 1
|
||||
|
||||
// Push messages.
|
||||
for _, s := range subs {
|
||||
msg, err := p.newMessage(s)
|
||||
if err != nil {
|
||||
p.m.log.Printf("error rendering message (%s) (%s): %v", p.camp.Name, s.Email, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Push the message to the queue while blocking and waiting until
|
||||
// the queue is drained.
|
||||
p.m.campMsgQ <- msg
|
||||
|
||||
// Check if the sliding window is active.
|
||||
if hasSliding {
|
||||
diff := time.Since(p.m.slidingStart)
|
||||
|
||||
// Window has expired. Reset the clock.
|
||||
if diff >= p.m.cfg.SlidingWindowDuration {
|
||||
p.m.slidingStart = time.Now()
|
||||
p.m.slidingCount = 0
|
||||
}
|
||||
|
||||
// Have the messages exceeded the limit?
|
||||
p.m.slidingCount++
|
||||
if p.m.slidingCount >= p.m.cfg.SlidingWindowRate {
|
||||
wait := p.m.cfg.SlidingWindowDuration - diff
|
||||
|
||||
p.m.log.Printf("messages exceeded (%d) for the window (%v since %s). Sleeping for %s.",
|
||||
p.m.slidingCount,
|
||||
p.m.cfg.SlidingWindowDuration,
|
||||
p.m.slidingStart.Format(time.RFC822Z),
|
||||
wait.Round(time.Second)*1)
|
||||
|
||||
p.m.slidingCount = 0
|
||||
time.Sleep(wait)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// OnError keeps track of the number of errors that occur while sending messages
|
||||
// and pauses the campaign if the error threshold is met.
|
||||
func (p *pipe) OnError() {
|
||||
if p.m.cfg.MaxSendErrors < 1 {
|
||||
return
|
||||
}
|
||||
|
||||
// If the error threshold is met, pause the campaign.
|
||||
count := p.errors.Add(1)
|
||||
if int(count) < p.m.cfg.MaxSendErrors {
|
||||
return
|
||||
}
|
||||
|
||||
p.Stop(true)
|
||||
p.m.log.Printf("error count exceeded %d. pausing campaign %s", p.m.cfg.MaxSendErrors, p.camp.Name)
|
||||
}
|
||||
|
||||
// Stop "marks" a campaign as stopped. It doesn't actually stop the processing
|
||||
// of messages. That happens when every queued message in the campaign is processed,
|
||||
// marking .wg, the waitgroup counter as done. That triggers cleanup().
|
||||
func (p *pipe) Stop(withErrors bool) {
|
||||
// Already stopped.
|
||||
if p.stopped.Load() {
|
||||
return
|
||||
}
|
||||
|
||||
if withErrors {
|
||||
p.withErrors.Store(true)
|
||||
}
|
||||
|
||||
p.stopped.Store(true)
|
||||
}
|
||||
|
||||
// newMessage returns a campaign message while internally incrementing the
|
||||
// number of messages in the pipe wait group so that the status of every
|
||||
// message can be atomically tracked.
|
||||
func (p *pipe) newMessage(s models.Subscriber) (CampaignMessage, error) {
|
||||
msg, err := p.m.NewCampaignMessage(p.camp, s)
|
||||
if err != nil {
|
||||
return msg, err
|
||||
}
|
||||
|
||||
msg.pipe = p
|
||||
p.wg.Add(1)
|
||||
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// cleanup finishes the campaign and updates the campaign status in the DB
|
||||
// and also triggers a notification to the admin. This only triggers once
|
||||
// a pipe's wg counter is fully exhausted, draining all messages in its queue.
|
||||
func (p *pipe) cleanup() {
|
||||
defer func() {
|
||||
p.m.pipesMut.Lock()
|
||||
delete(p.m.pipes, p.camp.ID)
|
||||
p.m.pipesMut.Unlock()
|
||||
}()
|
||||
|
||||
// Update campaign's 'sent count.
|
||||
if err := p.m.store.UpdateCampaignCounts(p.camp.ID, 0, int(p.sent.Load()), int(p.lastID.Load())); err != nil {
|
||||
p.m.log.Printf("error updating campaign counts (%s): %v", p.camp.Name, err)
|
||||
}
|
||||
|
||||
// The campaign was auto-paused due to errors.
|
||||
if p.withErrors.Load() {
|
||||
if err := p.m.store.UpdateCampaignStatus(p.camp.ID, models.CampaignStatusPaused); err != nil {
|
||||
p.m.log.Printf("error updating campaign (%s) status to %s: %v", p.camp.Name, models.CampaignStatusPaused, err)
|
||||
} else {
|
||||
p.m.log.Printf("set campaign (%s) to %s", p.camp.Name, models.CampaignStatusPaused)
|
||||
}
|
||||
|
||||
_ = p.m.sendNotif(p.camp, models.CampaignStatusPaused, "Too many errors")
|
||||
return
|
||||
}
|
||||
|
||||
// The campaign was manually stopped (pause, cancel).
|
||||
if p.stopped.Load() {
|
||||
p.m.log.Printf("stop processing campaign (%s)", p.camp.Name)
|
||||
return
|
||||
}
|
||||
|
||||
// Campaign wasn't manually stopped and subscribers were naturally exhausted.
|
||||
// Fetch the up-to-date campaign status from the DB.
|
||||
c, err := p.m.store.GetCampaign(p.camp.ID)
|
||||
if err != nil {
|
||||
p.m.log.Printf("error fetching campaign (%s) for ending: %v", p.camp.Name, err)
|
||||
return
|
||||
}
|
||||
|
||||
// If a running campaign has exhausted subscribers, it's finished.
|
||||
if c.Status == models.CampaignStatusRunning || c.Status == models.CampaignStatusScheduled {
|
||||
c.Status = models.CampaignStatusFinished
|
||||
if err := p.m.store.UpdateCampaignStatus(p.camp.ID, models.CampaignStatusFinished); err != nil {
|
||||
p.m.log.Printf("error finishing campaign (%s): %v", p.camp.Name, err)
|
||||
} else {
|
||||
p.m.log.Printf("campaign (%s) finished", p.camp.Name)
|
||||
}
|
||||
} else {
|
||||
p.m.log.Printf("finish processing campaign (%s)", p.camp.Name)
|
||||
}
|
||||
|
||||
// Notify admin.
|
||||
_ = p.m.sendNotif(c, c.Status, "")
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"source.offmarket.win/aleagle/eaglecast/models"
|
||||
"gopkg.in/volatiletech/null.v6"
|
||||
)
|
||||
|
||||
// Media represents an uploaded object.
|
||||
type Media struct {
|
||||
ID int `db:"id" json:"id"`
|
||||
UUID string `db:"uuid" json:"uuid"`
|
||||
Filename string `db:"filename" json:"filename"`
|
||||
ContentType string `db:"content_type" json:"content_type"`
|
||||
Thumb string `db:"thumb" json:"-"`
|
||||
CreatedAt null.Time `db:"created_at" json:"created_at"`
|
||||
ThumbURL null.String `json:"thumb_url"`
|
||||
Provider string `json:"provider"`
|
||||
Meta models.JSON `db:"meta" json:"meta"`
|
||||
URL string `json:"url"`
|
||||
|
||||
Total int `db:"total" json:"-"`
|
||||
}
|
||||
|
||||
// Store represents functions to store and retrieve media (files).
|
||||
type Store interface {
|
||||
Put(string, string, io.ReadSeeker) (string, error)
|
||||
Delete(string) error
|
||||
GetURL(string) string
|
||||
GetBlob(string) ([]byte, error)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"source.offmarket.win/aleagle/eaglecast/internal/media"
|
||||
)
|
||||
|
||||
// Opts represents filesystem params
|
||||
type Opts struct {
|
||||
UploadPath string `koanf:"upload_path"`
|
||||
UploadURI string `koanf:"upload_uri"`
|
||||
RootURL string `koanf:"root_url"`
|
||||
}
|
||||
|
||||
// Client implements `media.Store`
|
||||
type Client struct {
|
||||
opts Opts
|
||||
}
|
||||
|
||||
// New initialises store for Filesystem provider.
|
||||
func New(opts Opts) (media.Store, error) {
|
||||
return &Client{
|
||||
opts: opts,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Put accepts the filename, the content type and file object itself and stores the file in disk.
|
||||
func (c *Client) Put(filename string, cType string, src io.ReadSeeker) (string, error) {
|
||||
// Get the directory path
|
||||
dir := getDir(c.opts.UploadPath)
|
||||
|
||||
// Read the file contents.
|
||||
out, err := os.OpenFile(filepath.Join(dir, filename), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0664)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
// Copy it to the target location.
|
||||
if _, err := io.Copy(out, src); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return filename, nil
|
||||
}
|
||||
|
||||
// GetURL accepts a filename and retrieves the full path from disk.
|
||||
func (c *Client) GetURL(name string) string {
|
||||
return fmt.Sprintf("%s%s/%s", c.opts.RootURL, c.opts.UploadURI, name)
|
||||
}
|
||||
|
||||
// GetBlob accepts a URL, reads the file, and returns the blob.
|
||||
func (c *Client) GetBlob(url string) ([]byte, error) {
|
||||
b, err := os.ReadFile(filepath.Join(getDir(c.opts.UploadPath), filepath.Base(url)))
|
||||
return b, err
|
||||
}
|
||||
|
||||
// Delete accepts a filename and removes it from disk.
|
||||
func (c *Client) Delete(file string) error {
|
||||
dir := getDir(c.opts.UploadPath)
|
||||
|
||||
err := os.Remove(filepath.Join(dir, file))
|
||||
return err
|
||||
}
|
||||
|
||||
// getDir returns the current working directory path if no directory is specified,
|
||||
// else returns the directory path specified itself.
|
||||
func getDir(dir string) string {
|
||||
if dir == "" {
|
||||
dir, _ = os.Getwd()
|
||||
}
|
||||
|
||||
return dir
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package s3
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"source.offmarket.win/aleagle/eaglecast/internal/media"
|
||||
"github.com/rhnvrm/simples3"
|
||||
)
|
||||
|
||||
// Opt represents AWS S3 specific params
|
||||
type Opt struct {
|
||||
URL string `koanf:"url"`
|
||||
PublicURL string `koanf:"public_url"`
|
||||
AccessKey string `koanf:"aws_access_key_id"`
|
||||
SecretKey string `koanf:"aws_secret_access_key"`
|
||||
Region string `koanf:"aws_default_region"`
|
||||
Bucket string `koanf:"bucket"`
|
||||
BucketPath string `koanf:"bucket_path"`
|
||||
BucketType string `koanf:"bucket_type"`
|
||||
Expiry time.Duration `koanf:"expiry"`
|
||||
RootURL string `koanf:"root_url"`
|
||||
}
|
||||
|
||||
// Client implements `media.Store` for S3 provider
|
||||
type Client struct {
|
||||
s3 *simples3.S3
|
||||
opts Opt
|
||||
}
|
||||
|
||||
// NewS3Store initialises store for S3 provider. It takes in the AWS configuration
|
||||
// and sets up the `simples3` client to interact with AWS APIs for all bucket operations.
|
||||
func NewS3Store(opt Opt) (media.Store, error) {
|
||||
var cl *simples3.S3
|
||||
if opt.URL == "" {
|
||||
opt.URL = fmt.Sprintf("https://s3.%s.amazonaws.com", opt.Region)
|
||||
}
|
||||
opt.URL = strings.TrimRight(opt.URL, "/")
|
||||
|
||||
// Default (and max S3 expiry) is 7 days.
|
||||
if opt.Expiry.Seconds() < 1 {
|
||||
opt.Expiry = time.Duration(167) * time.Hour
|
||||
}
|
||||
|
||||
if opt.AccessKey == "" && opt.SecretKey == "" {
|
||||
// fallback to IAM role if no access key/secret key is provided.
|
||||
cl, _ = simples3.NewUsingIAM(opt.Region)
|
||||
}
|
||||
|
||||
if cl == nil {
|
||||
cl = simples3.New(opt.Region, opt.AccessKey, opt.SecretKey)
|
||||
}
|
||||
|
||||
cl.SetEndpoint(opt.URL)
|
||||
|
||||
return &Client{
|
||||
s3: cl,
|
||||
opts: opt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Put takes in the filename, the content type and file object itself and uploads to S3.
|
||||
func (c *Client) Put(name string, cType string, file io.ReadSeeker) (string, error) {
|
||||
// Upload input parameters
|
||||
p := simples3.UploadInput{
|
||||
Bucket: c.opts.Bucket,
|
||||
ContentType: cType,
|
||||
FileName: name,
|
||||
Body: file,
|
||||
|
||||
// Paths inside the bucket should not start with /.
|
||||
ObjectKey: c.makeBucketPath(name),
|
||||
}
|
||||
|
||||
if c.opts.BucketType == "public" {
|
||||
p.ACL = "public-read"
|
||||
}
|
||||
|
||||
// Upload.
|
||||
if _, err := c.s3.FilePut(p); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// Get accepts the filename of the object stored and retrieves from S3.
|
||||
func (c *Client) GetURL(name string) string {
|
||||
// Generate a private S3 pre-signed URL if it's a private bucket, and there
|
||||
// is no public URL provided.
|
||||
if c.opts.BucketType == "private" && c.opts.PublicURL == "" {
|
||||
u := c.s3.GeneratePresignedURL(simples3.PresignedInput{
|
||||
Bucket: c.opts.Bucket,
|
||||
ObjectKey: c.makeBucketPath(name),
|
||||
Method: "GET",
|
||||
Timestamp: time.Now(),
|
||||
ExpirySeconds: int(c.opts.Expiry.Seconds()),
|
||||
})
|
||||
|
||||
return u
|
||||
}
|
||||
|
||||
// Generate a public S3 URL if it's a public bucket or a public URL is
|
||||
// provided.
|
||||
return c.makeFileURL(name)
|
||||
}
|
||||
|
||||
// GetBlob reads a file from S3 and returns the raw bytes.
|
||||
func (c *Client) GetBlob(uurl string) ([]byte, error) {
|
||||
if p, err := url.Parse(uurl); err != nil {
|
||||
uurl = filepath.Base(uurl)
|
||||
} else {
|
||||
uurl = filepath.Base(p.Path)
|
||||
}
|
||||
|
||||
// Download the file from S3.
|
||||
file, err := c.s3.FileDownload(simples3.DownloadInput{
|
||||
Bucket: c.opts.Bucket,
|
||||
ObjectKey: c.makeBucketPath(filepath.Base(uurl)),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Read it into a byte blob.
|
||||
b, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// Delete accepts the filename of the object and deletes from S3.
|
||||
func (c *Client) Delete(name string) error {
|
||||
err := c.s3.FileDelete(simples3.DeleteInput{
|
||||
Bucket: c.opts.Bucket,
|
||||
ObjectKey: c.makeBucketPath(name),
|
||||
})
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// makeBucketPath returns the file path inside the bucket. The path should not
|
||||
// start with a /.
|
||||
func (c *Client) makeBucketPath(name string) string {
|
||||
// If the path is root (/), return the filename without the preceding slash.
|
||||
p := strings.TrimPrefix(strings.TrimSuffix(c.opts.BucketPath, "/"), "/")
|
||||
if p == "" {
|
||||
return name
|
||||
}
|
||||
|
||||
// whatever/bucket/path/filename.jpg: No preceding slash.
|
||||
return p + "/" + name
|
||||
}
|
||||
|
||||
func (c *Client) makeFileURL(name string) string {
|
||||
if c.opts.PublicURL != "" {
|
||||
prefix := c.opts.PublicURL
|
||||
if strings.HasPrefix(prefix, "/") {
|
||||
prefix = c.opts.RootURL + prefix
|
||||
}
|
||||
return prefix + "/" + c.makeBucketPath(name)
|
||||
}
|
||||
|
||||
return c.opts.URL + "/" + c.opts.Bucket + "/" + c.makeBucketPath(name)
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
"net/textproto"
|
||||
"strings"
|
||||
|
||||
"source.offmarket.win/aleagle/eaglecast/internal/utils"
|
||||
"source.offmarket.win/aleagle/eaglecast/models"
|
||||
"github.com/knadh/smtppool/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
MessengerName = "email"
|
||||
|
||||
hdrReturnPath = "Return-Path"
|
||||
hdrBcc = "Bcc"
|
||||
hdrCc = "Cc"
|
||||
hdrMessageID = "Message-Id"
|
||||
)
|
||||
|
||||
// Server represents an SMTP server's credentials.
|
||||
type Server struct {
|
||||
// Name is a unique identifier for the server.
|
||||
Name string `json:"name"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
AuthProtocol string `json:"auth_protocol"`
|
||||
TLSType string `json:"tls_type"`
|
||||
TLSSkipVerify bool `json:"tls_skip_verify"`
|
||||
EmailHeaders map[string]string `json:"email_headers"`
|
||||
FromAddresses []string `json:"from_addresses"`
|
||||
|
||||
// Rest of the options are embedded directly from the smtppool lib.
|
||||
// The JSON tag is for config unmarshal to work.
|
||||
//lint:ignore SA5008 ,squash is needed by koanf/mapstructure config unmarshal.
|
||||
smtppool.Opt `json:",squash"`
|
||||
|
||||
pool *smtppool.Pool
|
||||
}
|
||||
|
||||
// Emailer is the SMTP e-mail messenger.
|
||||
type Emailer struct {
|
||||
name string
|
||||
|
||||
// pools holds groups of SMTP servers indexed by a key ('from'-address
|
||||
// or a domain set per SMTPs server). An empty key holds all servers
|
||||
// and is the fallback round-robin when there's no match (old behaviour).
|
||||
pools map[string][]*Server
|
||||
}
|
||||
|
||||
// NormalizeAddr normalizes an e-mail address (strip spaces, lowercase).
|
||||
func NormalizeAddr(s string) string {
|
||||
return strings.ToLower(strings.TrimSpace(s))
|
||||
}
|
||||
|
||||
// New returns an SMTP e-mail Messenger backend with the given SMTP servers.
|
||||
// Group indicates whether the messenger represents a group of SMTP servers (1 or more)
|
||||
// that are used as a round-robin pool, or a single server.
|
||||
func New(name string, servers ...Server) (*Emailer, error) {
|
||||
e := &Emailer{
|
||||
name: name,
|
||||
pools: make(map[string][]*Server),
|
||||
}
|
||||
|
||||
for _, srv := range servers {
|
||||
s := srv
|
||||
|
||||
var auth smtp.Auth
|
||||
switch s.AuthProtocol {
|
||||
case "cram":
|
||||
auth = smtp.CRAMMD5Auth(s.Username, s.Password)
|
||||
case "plain":
|
||||
auth = smtp.PlainAuth("", s.Username, s.Password, s.Host)
|
||||
case "login":
|
||||
auth = &smtppool.LoginAuth{Username: s.Username, Password: s.Password}
|
||||
case "", "none":
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown SMTP auth type '%s'", s.AuthProtocol)
|
||||
}
|
||||
s.Opt.Auth = auth
|
||||
|
||||
// TLS config.
|
||||
s.Opt.SSL = smtppool.SSLNone
|
||||
if s.TLSType != "none" {
|
||||
s.TLSConfig = &tls.Config{}
|
||||
if s.TLSSkipVerify {
|
||||
s.TLSConfig.InsecureSkipVerify = s.TLSSkipVerify
|
||||
} else {
|
||||
s.TLSConfig.ServerName = s.Host
|
||||
}
|
||||
|
||||
// SSL/TLS, not STARTTLS.
|
||||
switch s.TLSType {
|
||||
case "TLS":
|
||||
s.Opt.SSL = smtppool.SSLTLS
|
||||
case "STARTTLS":
|
||||
s.Opt.SSL = smtppool.SSLSTARTTLS
|
||||
}
|
||||
}
|
||||
|
||||
pool, err := smtppool.New(s.Opt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.pool = pool
|
||||
|
||||
// Add to the global list (empty key) and to each from-address
|
||||
// bucket. Duplicate keys across servers are fine and get round-robin'd.
|
||||
e.pools[""] = append(e.pools[""], &s)
|
||||
for _, addr := range s.FromAddresses {
|
||||
if key := NormalizeAddr(addr); key != "" {
|
||||
e.pools[key] = append(e.pools[key], &s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// Name returns the messenger's name.
|
||||
func (e *Emailer) Name() string {
|
||||
return e.name
|
||||
}
|
||||
|
||||
// Push pushes a message to the server.
|
||||
func (e *Emailer) Push(m models.Message) error {
|
||||
// Pick the from-address-routed pool if there is one, else default
|
||||
// to the full pool (empty key) for roundrobin.
|
||||
pool := e.pools[""]
|
||||
if len(e.pools) > 1 {
|
||||
if srvs := e.getPool(m.From); srvs != nil {
|
||||
pool = srvs
|
||||
}
|
||||
}
|
||||
srv := pool[rand.Intn(len(pool))]
|
||||
|
||||
// Are there attachments?
|
||||
var files []smtppool.Attachment
|
||||
if m.Attachments != nil {
|
||||
files = make([]smtppool.Attachment, 0, len(m.Attachments))
|
||||
for _, f := range m.Attachments {
|
||||
a := smtppool.Attachment{
|
||||
Filename: f.Name,
|
||||
Header: f.Header,
|
||||
Content: make([]byte, len(f.Content)),
|
||||
HTMLRelated: f.IsInline,
|
||||
}
|
||||
copy(a.Content, f.Content)
|
||||
files = append(files, a)
|
||||
}
|
||||
}
|
||||
|
||||
// Create the email.
|
||||
em := smtppool.Email{
|
||||
From: m.From,
|
||||
To: m.To,
|
||||
Subject: m.Subject,
|
||||
Attachments: files,
|
||||
}
|
||||
|
||||
em.Headers = textproto.MIMEHeader{}
|
||||
|
||||
// Attach SMTP level headers.
|
||||
for k, v := range srv.EmailHeaders {
|
||||
em.Headers.Set(k, v)
|
||||
}
|
||||
|
||||
// Attach e-mail level headers.
|
||||
for k, v := range m.Headers {
|
||||
em.Headers.Set(k, v[0])
|
||||
}
|
||||
|
||||
// Generate Message-Id based on the From address.
|
||||
if em.Headers.Get(hdrMessageID) == "" {
|
||||
d := "localhost"
|
||||
if a, err := mail.ParseAddress(m.From); err == nil {
|
||||
d = a.Address[strings.LastIndex(a.Address, "@")+1:]
|
||||
}
|
||||
if r, err := utils.GenerateRandomString(24); err == nil {
|
||||
em.Headers.Set(hdrMessageID, fmt.Sprintf("<%s@%s>", r, d))
|
||||
}
|
||||
}
|
||||
|
||||
// If the `Return-Path` header is set, it should be set as the
|
||||
// the SMTP envelope sender (via the Sender field of the email struct).
|
||||
if sender := em.Headers.Get(hdrReturnPath); sender != "" {
|
||||
em.Sender = sender
|
||||
em.Headers.Del(hdrReturnPath)
|
||||
}
|
||||
|
||||
// If the `Bcc` header is set, it should be set on the Envelope
|
||||
if bcc := em.Headers.Get(hdrBcc); bcc != "" {
|
||||
for _, part := range strings.Split(bcc, ",") {
|
||||
em.Bcc = append(em.Bcc, strings.TrimSpace(part))
|
||||
}
|
||||
em.Headers.Del(hdrBcc)
|
||||
}
|
||||
|
||||
// If the `Cc` header is set, it should be set on the Envelope
|
||||
if cc := em.Headers.Get(hdrCc); cc != "" {
|
||||
for _, part := range strings.Split(cc, ",") {
|
||||
em.Cc = append(em.Cc, strings.TrimSpace(part))
|
||||
}
|
||||
em.Headers.Del(hdrCc)
|
||||
}
|
||||
|
||||
switch m.ContentType {
|
||||
case "plain":
|
||||
em.Text = []byte(m.Body)
|
||||
default:
|
||||
em.HTML = m.Body
|
||||
if len(m.AltBody) > 0 {
|
||||
em.Text = m.AltBody
|
||||
}
|
||||
}
|
||||
|
||||
return srv.pool.Send(em)
|
||||
}
|
||||
|
||||
// Flush flushes the message queue to the server.
|
||||
func (e *Emailer) Flush() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the SMTP pools.
|
||||
func (e *Emailer) Close() error {
|
||||
for _, s := range e.pools[""] {
|
||||
s.pool.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getPool returns the pool of servers configured to handle the given From
|
||||
// header, matched by full e-mail and then by domain.
|
||||
// Returns nil if no mapping matches.
|
||||
func (e *Emailer) getPool(from string) []*Server {
|
||||
addr := utils.ParseEmailAddress(from)
|
||||
if addr == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if srvs, ok := e.pools[addr]; ok {
|
||||
return srvs
|
||||
}
|
||||
|
||||
if _, after, ok := strings.Cut(addr, "@"); ok {
|
||||
return e.pools[after]
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package postback
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"time"
|
||||
|
||||
"source.offmarket.win/aleagle/eaglecast/models"
|
||||
)
|
||||
|
||||
// postback is the payload that's posted as JSON to the HTTP Postback server.
|
||||
//
|
||||
//easyjson:json
|
||||
type postback struct {
|
||||
Subject string `json:"subject"`
|
||||
FromEmail string `json:"from_email"`
|
||||
ContentType string `json:"content_type"`
|
||||
Body string `json:"body"`
|
||||
Recipients []recipient `json:"recipients"`
|
||||
Campaign *campaign `json:"campaign"`
|
||||
Attachments []attachment `json:"attachments"`
|
||||
}
|
||||
|
||||
type campaign struct {
|
||||
FromEmail string `json:"from_email"`
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Headers models.Headers `json:"headers"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
type recipient struct {
|
||||
UUID string `json:"uuid"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Attribs models.JSON `json:"attribs"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type attachment struct {
|
||||
Name string `json:"name"`
|
||||
Header textproto.MIMEHeader `json:"header"`
|
||||
Content []byte `json:"content"`
|
||||
}
|
||||
|
||||
// Options represents HTTP Postback server options.
|
||||
type Options struct {
|
||||
Name string `json:"name"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
RootURL string `json:"root_url"`
|
||||
MaxConns int `json:"max_conns"`
|
||||
Retries int `json:"retries"`
|
||||
Timeout time.Duration `json:"timeout"`
|
||||
}
|
||||
|
||||
// Postback represents an HTTP Message server.
|
||||
type Postback struct {
|
||||
authStr string
|
||||
o Options
|
||||
c *http.Client
|
||||
}
|
||||
|
||||
// New returns a new instance of the HTTP Postback messenger.
|
||||
func New(o Options) (*Postback, error) {
|
||||
authStr := ""
|
||||
if o.Username != "" && o.Password != "" {
|
||||
authStr = fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString(
|
||||
[]byte(o.Username+":"+o.Password)))
|
||||
}
|
||||
|
||||
return &Postback{
|
||||
authStr: authStr,
|
||||
o: o,
|
||||
c: &http.Client{
|
||||
Timeout: o.Timeout,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConnsPerHost: o.MaxConns,
|
||||
MaxConnsPerHost: o.MaxConns,
|
||||
ResponseHeaderTimeout: o.Timeout,
|
||||
IdleConnTimeout: o.Timeout,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Name returns the messenger's name.
|
||||
func (p *Postback) Name() string {
|
||||
return p.o.Name
|
||||
}
|
||||
|
||||
// Push pushes a message to the server.
|
||||
func (p *Postback) Push(m models.Message) error {
|
||||
pb := postback{
|
||||
Subject: m.Subject,
|
||||
FromEmail: m.From,
|
||||
ContentType: m.ContentType,
|
||||
Body: string(m.Body),
|
||||
Recipients: []recipient{{
|
||||
UUID: m.Subscriber.UUID,
|
||||
Email: m.Subscriber.Email,
|
||||
Name: m.Subscriber.Name,
|
||||
Status: m.Subscriber.Status,
|
||||
Attribs: m.Subscriber.Attribs,
|
||||
}},
|
||||
}
|
||||
|
||||
if m.Campaign != nil {
|
||||
pb.Campaign = &campaign{
|
||||
FromEmail: m.Campaign.FromEmail,
|
||||
UUID: m.Campaign.UUID,
|
||||
Name: m.Campaign.Name,
|
||||
Headers: m.Campaign.Headers,
|
||||
Tags: m.Campaign.Tags,
|
||||
}
|
||||
}
|
||||
|
||||
if len(m.Attachments) > 0 {
|
||||
files := make([]attachment, 0, len(m.Attachments))
|
||||
for _, f := range m.Attachments {
|
||||
a := attachment{
|
||||
Name: f.Name,
|
||||
Header: f.Header,
|
||||
Content: make([]byte, len(f.Content)),
|
||||
}
|
||||
copy(a.Content, f.Content)
|
||||
files = append(files, a)
|
||||
}
|
||||
pb.Attachments = files
|
||||
}
|
||||
|
||||
b, err := pb.MarshalJSON()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return p.exec(http.MethodPost, p.o.RootURL, b, nil)
|
||||
}
|
||||
|
||||
// Flush flushes the message queue to the server.
|
||||
func (p *Postback) Flush() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes idle HTTP connections.
|
||||
func (p *Postback) Close() error {
|
||||
p.c.CloseIdleConnections()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Postback) exec(method, rURL string, reqBody []byte, headers http.Header) error {
|
||||
var (
|
||||
err error
|
||||
postBody io.Reader
|
||||
)
|
||||
|
||||
// Encode POST / PUT params.
|
||||
if method == http.MethodPost || method == http.MethodPut {
|
||||
postBody = bytes.NewReader(reqBody)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, rURL, postBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if headers != nil {
|
||||
req.Header = headers
|
||||
} else {
|
||||
req.Header = http.Header{}
|
||||
}
|
||||
req.Header.Set("User-Agent", "eaglecast")
|
||||
|
||||
// Optional BasicAuth.
|
||||
if p.authStr != "" {
|
||||
req.Header.Set("Authorization", p.authStr)
|
||||
}
|
||||
|
||||
// If a content-type isn't set, set the default one.
|
||||
if req.Header.Get("Content-Type") == "" {
|
||||
if method == http.MethodPost || method == http.MethodPut {
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
}
|
||||
}
|
||||
|
||||
// If the request method is GET or DELETE, add the params as QueryString.
|
||||
if method == http.MethodGet || method == http.MethodDelete {
|
||||
req.URL.RawQuery = string(reqBody)
|
||||
}
|
||||
|
||||
// Execute the request.
|
||||
r, err := p.c.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
// Drain and close the body to let the Transport reuse the connection
|
||||
io.Copy(io.Discard, r.Body)
|
||||
r.Body.Close()
|
||||
}()
|
||||
|
||||
if r.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("non-OK response from Postback server: %d", r.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
// Code generated by easyjson for marshaling/unmarshaling. DO NOT EDIT.
|
||||
|
||||
package postback
|
||||
|
||||
import (
|
||||
json "encoding/json"
|
||||
models "source.offmarket.win/aleagle/eaglecast/models"
|
||||
easyjson "github.com/zerodha/easyjson"
|
||||
jlexer "github.com/zerodha/easyjson/jlexer"
|
||||
jwriter "github.com/zerodha/easyjson/jwriter"
|
||||
textproto "net/textproto"
|
||||
)
|
||||
|
||||
// suppress unused package warning
|
||||
var (
|
||||
_ *json.RawMessage
|
||||
_ *jlexer.Lexer
|
||||
_ *jwriter.Writer
|
||||
_ easyjson.Marshaler
|
||||
)
|
||||
|
||||
func easyjsonDf11841fDecodeGithubComKnadhEagleCastInternalMessengerPostback(in *jlexer.Lexer, out *postback) {
|
||||
isTopLevel := in.IsStart()
|
||||
if in.IsNull() {
|
||||
if isTopLevel {
|
||||
in.Consumed()
|
||||
}
|
||||
in.Skip()
|
||||
return
|
||||
}
|
||||
in.Delim('{')
|
||||
for !in.IsDelim('}') {
|
||||
key := in.UnsafeFieldName(false)
|
||||
in.WantColon()
|
||||
if in.IsNull() {
|
||||
in.Skip()
|
||||
in.WantComma()
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "subject":
|
||||
out.Subject = string(in.String())
|
||||
case "from_email":
|
||||
out.FromEmail = string(in.String())
|
||||
case "content_type":
|
||||
out.ContentType = string(in.String())
|
||||
case "body":
|
||||
out.Body = string(in.String())
|
||||
case "recipients":
|
||||
if in.IsNull() {
|
||||
in.Skip()
|
||||
out.Recipients = nil
|
||||
} else {
|
||||
in.Delim('[')
|
||||
if out.Recipients == nil {
|
||||
if !in.IsDelim(']') {
|
||||
out.Recipients = make([]recipient, 0, 0)
|
||||
} else {
|
||||
out.Recipients = []recipient{}
|
||||
}
|
||||
} else {
|
||||
out.Recipients = (out.Recipients)[:0]
|
||||
}
|
||||
for !in.IsDelim(']') {
|
||||
var v1 recipient
|
||||
easyjsonDf11841fDecodeGithubComKnadhEagleCastInternalMessengerPostback1(in, &v1)
|
||||
out.Recipients = append(out.Recipients, v1)
|
||||
in.WantComma()
|
||||
}
|
||||
in.Delim(']')
|
||||
}
|
||||
case "campaign":
|
||||
if in.IsNull() {
|
||||
in.Skip()
|
||||
out.Campaign = nil
|
||||
} else {
|
||||
if out.Campaign == nil {
|
||||
out.Campaign = new(campaign)
|
||||
}
|
||||
easyjsonDf11841fDecodeGithubComKnadhEagleCastInternalMessengerPostback2(in, out.Campaign)
|
||||
}
|
||||
case "attachments":
|
||||
if in.IsNull() {
|
||||
in.Skip()
|
||||
out.Attachments = nil
|
||||
} else {
|
||||
in.Delim('[')
|
||||
if out.Attachments == nil {
|
||||
if !in.IsDelim(']') {
|
||||
out.Attachments = make([]attachment, 0, 1)
|
||||
} else {
|
||||
out.Attachments = []attachment{}
|
||||
}
|
||||
} else {
|
||||
out.Attachments = (out.Attachments)[:0]
|
||||
}
|
||||
for !in.IsDelim(']') {
|
||||
var v2 attachment
|
||||
easyjsonDf11841fDecodeGithubComKnadhEagleCastInternalMessengerPostback3(in, &v2)
|
||||
out.Attachments = append(out.Attachments, v2)
|
||||
in.WantComma()
|
||||
}
|
||||
in.Delim(']')
|
||||
}
|
||||
default:
|
||||
in.SkipRecursive()
|
||||
}
|
||||
in.WantComma()
|
||||
}
|
||||
in.Delim('}')
|
||||
if isTopLevel {
|
||||
in.Consumed()
|
||||
}
|
||||
}
|
||||
func easyjsonDf11841fEncodeGithubComKnadhEagleCastInternalMessengerPostback(out *jwriter.Writer, in postback) {
|
||||
out.RawByte('{')
|
||||
first := true
|
||||
_ = first
|
||||
{
|
||||
const prefix string = ",\"subject\":"
|
||||
out.RawString(prefix[1:])
|
||||
out.String(string(in.Subject))
|
||||
}
|
||||
{
|
||||
const prefix string = ",\"from_email\":"
|
||||
out.RawString(prefix)
|
||||
out.String(string(in.FromEmail))
|
||||
}
|
||||
{
|
||||
const prefix string = ",\"content_type\":"
|
||||
out.RawString(prefix)
|
||||
out.String(string(in.ContentType))
|
||||
}
|
||||
{
|
||||
const prefix string = ",\"body\":"
|
||||
out.RawString(prefix)
|
||||
out.String(string(in.Body))
|
||||
}
|
||||
{
|
||||
const prefix string = ",\"recipients\":"
|
||||
out.RawString(prefix)
|
||||
if in.Recipients == nil && (out.Flags&jwriter.NilSliceAsEmpty) == 0 {
|
||||
out.RawString("null")
|
||||
} else {
|
||||
out.RawByte('[')
|
||||
for v3, v4 := range in.Recipients {
|
||||
if v3 > 0 {
|
||||
out.RawByte(',')
|
||||
}
|
||||
easyjsonDf11841fEncodeGithubComKnadhEagleCastInternalMessengerPostback1(out, v4)
|
||||
}
|
||||
out.RawByte(']')
|
||||
}
|
||||
}
|
||||
{
|
||||
const prefix string = ",\"campaign\":"
|
||||
out.RawString(prefix)
|
||||
if in.Campaign == nil {
|
||||
out.RawString("null")
|
||||
} else {
|
||||
easyjsonDf11841fEncodeGithubComKnadhEagleCastInternalMessengerPostback2(out, *in.Campaign)
|
||||
}
|
||||
}
|
||||
{
|
||||
const prefix string = ",\"attachments\":"
|
||||
out.RawString(prefix)
|
||||
if in.Attachments == nil && (out.Flags&jwriter.NilSliceAsEmpty) == 0 {
|
||||
out.RawString("null")
|
||||
} else {
|
||||
out.RawByte('[')
|
||||
for v5, v6 := range in.Attachments {
|
||||
if v5 > 0 {
|
||||
out.RawByte(',')
|
||||
}
|
||||
easyjsonDf11841fEncodeGithubComKnadhEagleCastInternalMessengerPostback3(out, v6)
|
||||
}
|
||||
out.RawByte(']')
|
||||
}
|
||||
}
|
||||
out.RawByte('}')
|
||||
}
|
||||
|
||||
// MarshalJSON supports json.Marshaler interface
|
||||
func (v postback) MarshalJSON() ([]byte, error) {
|
||||
w := jwriter.Writer{FloatFmt: ""}
|
||||
easyjsonDf11841fEncodeGithubComKnadhEagleCastInternalMessengerPostback(&w, v)
|
||||
return w.Buffer.BuildBytes(), w.Error
|
||||
}
|
||||
|
||||
// MarshalEasyJSON supports easyjson.Marshaler interface
|
||||
func (v postback) MarshalEasyJSON(w *jwriter.Writer) {
|
||||
easyjsonDf11841fEncodeGithubComKnadhEagleCastInternalMessengerPostback(w, v)
|
||||
}
|
||||
|
||||
// UnmarshalJSON supports json.Unmarshaler interface
|
||||
func (v *postback) UnmarshalJSON(data []byte) error {
|
||||
r := jlexer.Lexer{Data: data}
|
||||
easyjsonDf11841fDecodeGithubComKnadhEagleCastInternalMessengerPostback(&r, v)
|
||||
return r.Error()
|
||||
}
|
||||
|
||||
// UnmarshalEasyJSON supports easyjson.Unmarshaler interface
|
||||
func (v *postback) UnmarshalEasyJSON(l *jlexer.Lexer) {
|
||||
easyjsonDf11841fDecodeGithubComKnadhEagleCastInternalMessengerPostback(l, v)
|
||||
}
|
||||
func easyjsonDf11841fDecodeGithubComKnadhEagleCastInternalMessengerPostback3(in *jlexer.Lexer, out *attachment) {
|
||||
isTopLevel := in.IsStart()
|
||||
if in.IsNull() {
|
||||
if isTopLevel {
|
||||
in.Consumed()
|
||||
}
|
||||
in.Skip()
|
||||
return
|
||||
}
|
||||
in.Delim('{')
|
||||
for !in.IsDelim('}') {
|
||||
key := in.UnsafeFieldName(false)
|
||||
in.WantColon()
|
||||
if in.IsNull() {
|
||||
in.Skip()
|
||||
in.WantComma()
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "name":
|
||||
out.Name = string(in.String())
|
||||
case "header":
|
||||
if in.IsNull() {
|
||||
in.Skip()
|
||||
} else {
|
||||
in.Delim('{')
|
||||
out.Header = make(textproto.MIMEHeader)
|
||||
for !in.IsDelim('}') {
|
||||
key := string(in.String())
|
||||
in.WantColon()
|
||||
var v7 []string
|
||||
if in.IsNull() {
|
||||
in.Skip()
|
||||
v7 = nil
|
||||
} else {
|
||||
in.Delim('[')
|
||||
if v7 == nil {
|
||||
if !in.IsDelim(']') {
|
||||
v7 = make([]string, 0, 4)
|
||||
} else {
|
||||
v7 = []string{}
|
||||
}
|
||||
} else {
|
||||
v7 = (v7)[:0]
|
||||
}
|
||||
for !in.IsDelim(']') {
|
||||
var v8 string
|
||||
v8 = string(in.String())
|
||||
v7 = append(v7, v8)
|
||||
in.WantComma()
|
||||
}
|
||||
in.Delim(']')
|
||||
}
|
||||
(out.Header)[key] = v7
|
||||
in.WantComma()
|
||||
}
|
||||
in.Delim('}')
|
||||
}
|
||||
case "content":
|
||||
if in.IsNull() {
|
||||
in.Skip()
|
||||
out.Content = nil
|
||||
} else {
|
||||
out.Content = in.Bytes()
|
||||
}
|
||||
default:
|
||||
in.SkipRecursive()
|
||||
}
|
||||
in.WantComma()
|
||||
}
|
||||
in.Delim('}')
|
||||
if isTopLevel {
|
||||
in.Consumed()
|
||||
}
|
||||
}
|
||||
func easyjsonDf11841fEncodeGithubComKnadhEagleCastInternalMessengerPostback3(out *jwriter.Writer, in attachment) {
|
||||
out.RawByte('{')
|
||||
first := true
|
||||
_ = first
|
||||
{
|
||||
const prefix string = ",\"name\":"
|
||||
out.RawString(prefix[1:])
|
||||
out.String(string(in.Name))
|
||||
}
|
||||
{
|
||||
const prefix string = ",\"header\":"
|
||||
out.RawString(prefix)
|
||||
if in.Header == nil && (out.Flags&jwriter.NilMapAsEmpty) == 0 {
|
||||
out.RawString(`null`)
|
||||
} else {
|
||||
out.RawByte('{')
|
||||
v10First := true
|
||||
for v10Name, v10Value := range in.Header {
|
||||
if v10First {
|
||||
v10First = false
|
||||
} else {
|
||||
out.RawByte(',')
|
||||
}
|
||||
out.String(string(v10Name))
|
||||
out.RawByte(':')
|
||||
if v10Value == nil && (out.Flags&jwriter.NilSliceAsEmpty) == 0 {
|
||||
out.RawString("null")
|
||||
} else {
|
||||
out.RawByte('[')
|
||||
for v11, v12 := range v10Value {
|
||||
if v11 > 0 {
|
||||
out.RawByte(',')
|
||||
}
|
||||
out.String(string(v12))
|
||||
}
|
||||
out.RawByte(']')
|
||||
}
|
||||
}
|
||||
out.RawByte('}')
|
||||
}
|
||||
}
|
||||
{
|
||||
const prefix string = ",\"content\":"
|
||||
out.RawString(prefix)
|
||||
out.Base64Bytes(in.Content)
|
||||
}
|
||||
out.RawByte('}')
|
||||
}
|
||||
func easyjsonDf11841fDecodeGithubComKnadhEagleCastInternalMessengerPostback2(in *jlexer.Lexer, out *campaign) {
|
||||
isTopLevel := in.IsStart()
|
||||
if in.IsNull() {
|
||||
if isTopLevel {
|
||||
in.Consumed()
|
||||
}
|
||||
in.Skip()
|
||||
return
|
||||
}
|
||||
in.Delim('{')
|
||||
for !in.IsDelim('}') {
|
||||
key := in.UnsafeFieldName(false)
|
||||
in.WantColon()
|
||||
if in.IsNull() {
|
||||
in.Skip()
|
||||
in.WantComma()
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "from_email":
|
||||
out.FromEmail = string(in.String())
|
||||
case "uuid":
|
||||
out.UUID = string(in.String())
|
||||
case "name":
|
||||
out.Name = string(in.String())
|
||||
case "headers":
|
||||
if in.IsNull() {
|
||||
in.Skip()
|
||||
out.Headers = nil
|
||||
} else {
|
||||
in.Delim('[')
|
||||
if out.Headers == nil {
|
||||
if !in.IsDelim(']') {
|
||||
out.Headers = make(models.Headers, 0, 8)
|
||||
} else {
|
||||
out.Headers = models.Headers{}
|
||||
}
|
||||
} else {
|
||||
out.Headers = (out.Headers)[:0]
|
||||
}
|
||||
for !in.IsDelim(']') {
|
||||
var v15 map[string]string
|
||||
if in.IsNull() {
|
||||
in.Skip()
|
||||
} else {
|
||||
in.Delim('{')
|
||||
v15 = make(map[string]string)
|
||||
for !in.IsDelim('}') {
|
||||
key := string(in.String())
|
||||
in.WantColon()
|
||||
var v16 string
|
||||
v16 = string(in.String())
|
||||
(v15)[key] = v16
|
||||
in.WantComma()
|
||||
}
|
||||
in.Delim('}')
|
||||
}
|
||||
out.Headers = append(out.Headers, v15)
|
||||
in.WantComma()
|
||||
}
|
||||
in.Delim(']')
|
||||
}
|
||||
case "tags":
|
||||
if in.IsNull() {
|
||||
in.Skip()
|
||||
out.Tags = nil
|
||||
} else {
|
||||
in.Delim('[')
|
||||
if out.Tags == nil {
|
||||
if !in.IsDelim(']') {
|
||||
out.Tags = make([]string, 0, 4)
|
||||
} else {
|
||||
out.Tags = []string{}
|
||||
}
|
||||
} else {
|
||||
out.Tags = (out.Tags)[:0]
|
||||
}
|
||||
for !in.IsDelim(']') {
|
||||
var v17 string
|
||||
v17 = string(in.String())
|
||||
out.Tags = append(out.Tags, v17)
|
||||
in.WantComma()
|
||||
}
|
||||
in.Delim(']')
|
||||
}
|
||||
default:
|
||||
in.SkipRecursive()
|
||||
}
|
||||
in.WantComma()
|
||||
}
|
||||
in.Delim('}')
|
||||
if isTopLevel {
|
||||
in.Consumed()
|
||||
}
|
||||
}
|
||||
func easyjsonDf11841fEncodeGithubComKnadhEagleCastInternalMessengerPostback2(out *jwriter.Writer, in campaign) {
|
||||
out.RawByte('{')
|
||||
first := true
|
||||
_ = first
|
||||
{
|
||||
const prefix string = ",\"from_email\":"
|
||||
out.RawString(prefix[1:])
|
||||
out.String(string(in.FromEmail))
|
||||
}
|
||||
{
|
||||
const prefix string = ",\"uuid\":"
|
||||
out.RawString(prefix)
|
||||
out.String(string(in.UUID))
|
||||
}
|
||||
{
|
||||
const prefix string = ",\"name\":"
|
||||
out.RawString(prefix)
|
||||
out.String(string(in.Name))
|
||||
}
|
||||
{
|
||||
const prefix string = ",\"headers\":"
|
||||
out.RawString(prefix)
|
||||
if in.Headers == nil && (out.Flags&jwriter.NilSliceAsEmpty) == 0 {
|
||||
out.RawString("null")
|
||||
} else {
|
||||
out.RawByte('[')
|
||||
for v18, v19 := range in.Headers {
|
||||
if v18 > 0 {
|
||||
out.RawByte(',')
|
||||
}
|
||||
if v19 == nil && (out.Flags&jwriter.NilMapAsEmpty) == 0 {
|
||||
out.RawString(`null`)
|
||||
} else {
|
||||
out.RawByte('{')
|
||||
v20First := true
|
||||
for v20Name, v20Value := range v19 {
|
||||
if v20First {
|
||||
v20First = false
|
||||
} else {
|
||||
out.RawByte(',')
|
||||
}
|
||||
out.String(string(v20Name))
|
||||
out.RawByte(':')
|
||||
out.String(string(v20Value))
|
||||
}
|
||||
out.RawByte('}')
|
||||
}
|
||||
}
|
||||
out.RawByte(']')
|
||||
}
|
||||
}
|
||||
{
|
||||
const prefix string = ",\"tags\":"
|
||||
out.RawString(prefix)
|
||||
if in.Tags == nil && (out.Flags&jwriter.NilSliceAsEmpty) == 0 {
|
||||
out.RawString("null")
|
||||
} else {
|
||||
out.RawByte('[')
|
||||
for v21, v22 := range in.Tags {
|
||||
if v21 > 0 {
|
||||
out.RawByte(',')
|
||||
}
|
||||
out.String(string(v22))
|
||||
}
|
||||
out.RawByte(']')
|
||||
}
|
||||
}
|
||||
out.RawByte('}')
|
||||
}
|
||||
func easyjsonDf11841fDecodeGithubComKnadhEagleCastInternalMessengerPostback1(in *jlexer.Lexer, out *recipient) {
|
||||
isTopLevel := in.IsStart()
|
||||
if in.IsNull() {
|
||||
if isTopLevel {
|
||||
in.Consumed()
|
||||
}
|
||||
in.Skip()
|
||||
return
|
||||
}
|
||||
in.Delim('{')
|
||||
for !in.IsDelim('}') {
|
||||
key := in.UnsafeFieldName(false)
|
||||
in.WantColon()
|
||||
if in.IsNull() {
|
||||
in.Skip()
|
||||
in.WantComma()
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "uuid":
|
||||
out.UUID = string(in.String())
|
||||
case "email":
|
||||
out.Email = string(in.String())
|
||||
case "name":
|
||||
out.Name = string(in.String())
|
||||
case "attribs":
|
||||
if in.IsNull() {
|
||||
in.Skip()
|
||||
} else {
|
||||
in.Delim('{')
|
||||
out.Attribs = make(models.JSON)
|
||||
for !in.IsDelim('}') {
|
||||
key := string(in.String())
|
||||
in.WantColon()
|
||||
var v23 interface{}
|
||||
if m, ok := v23.(easyjson.Unmarshaler); ok {
|
||||
m.UnmarshalEasyJSON(in)
|
||||
} else if m, ok := v23.(json.Unmarshaler); ok {
|
||||
_ = m.UnmarshalJSON(in.Raw())
|
||||
} else {
|
||||
v23 = in.Interface()
|
||||
}
|
||||
(out.Attribs)[key] = v23
|
||||
in.WantComma()
|
||||
}
|
||||
in.Delim('}')
|
||||
}
|
||||
case "status":
|
||||
out.Status = string(in.String())
|
||||
default:
|
||||
in.SkipRecursive()
|
||||
}
|
||||
in.WantComma()
|
||||
}
|
||||
in.Delim('}')
|
||||
if isTopLevel {
|
||||
in.Consumed()
|
||||
}
|
||||
}
|
||||
func easyjsonDf11841fEncodeGithubComKnadhEagleCastInternalMessengerPostback1(out *jwriter.Writer, in recipient) {
|
||||
out.RawByte('{')
|
||||
first := true
|
||||
_ = first
|
||||
{
|
||||
const prefix string = ",\"uuid\":"
|
||||
out.RawString(prefix[1:])
|
||||
out.String(string(in.UUID))
|
||||
}
|
||||
{
|
||||
const prefix string = ",\"email\":"
|
||||
out.RawString(prefix)
|
||||
out.String(string(in.Email))
|
||||
}
|
||||
{
|
||||
const prefix string = ",\"name\":"
|
||||
out.RawString(prefix)
|
||||
out.String(string(in.Name))
|
||||
}
|
||||
{
|
||||
const prefix string = ",\"attribs\":"
|
||||
out.RawString(prefix)
|
||||
if in.Attribs == nil && (out.Flags&jwriter.NilMapAsEmpty) == 0 {
|
||||
out.RawString(`null`)
|
||||
} else {
|
||||
out.RawByte('{')
|
||||
v24First := true
|
||||
for v24Name, v24Value := range in.Attribs {
|
||||
if v24First {
|
||||
v24First = false
|
||||
} else {
|
||||
out.RawByte(',')
|
||||
}
|
||||
out.String(string(v24Name))
|
||||
out.RawByte(':')
|
||||
if m, ok := v24Value.(easyjson.Marshaler); ok {
|
||||
m.MarshalEasyJSON(out)
|
||||
} else if m, ok := v24Value.(json.Marshaler); ok {
|
||||
out.Raw(m.MarshalJSON())
|
||||
} else {
|
||||
out.Raw(json.Marshal(v24Value))
|
||||
}
|
||||
}
|
||||
out.RawByte('}')
|
||||
}
|
||||
}
|
||||
{
|
||||
const prefix string = ",\"status\":"
|
||||
out.RawString(prefix)
|
||||
out.String(string(in.Status))
|
||||
}
|
||||
out.RawByte('}')
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"github.com/knadh/stuffbin"
|
||||
)
|
||||
|
||||
// V0_4_0 performs the DB migrations for v.0.4.0.
|
||||
func V0_4_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
|
||||
_, err := db.Exec(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'list_optin') THEN
|
||||
CREATE TYPE list_optin AS ENUM ('single', 'double');
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'campaign_type') THEN
|
||||
CREATE TYPE campaign_type AS ENUM ('regular', 'optin');
|
||||
END IF;
|
||||
END$$;
|
||||
|
||||
ALTER TABLE lists ADD COLUMN IF NOT EXISTS optin list_optin NOT NULL DEFAULT 'single';
|
||||
ALTER TABLE campaigns ADD COLUMN IF NOT EXISTS type campaign_type DEFAULT 'regular';
|
||||
`)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"github.com/knadh/stuffbin"
|
||||
)
|
||||
|
||||
// V0_7_0 performs the DB migrations for v.0.7.0.
|
||||
func V0_7_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
|
||||
// Check if the subscriber_status.blocklisted enum value exists. If not,
|
||||
// it has to be created (for the change from blacklisted -> blocklisted).
|
||||
var bl bool
|
||||
if err := db.Get(&bl, `SELECT 'blocklisted' = ANY(ENUM_RANGE(NULL::subscriber_status)::TEXT[])`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If `blocklist` doesn't exist, add it to the subscriber_status enum,
|
||||
// and update existing statuses to this value. Unfortunately, it's not possible
|
||||
// to remove the enum value `blacklisted` (until PG10).
|
||||
if !bl {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.Exec(`
|
||||
-- Change the status column to text.
|
||||
ALTER TABLE subscribers ALTER COLUMN status TYPE TEXT;
|
||||
|
||||
-- Change all statuses from 'blacklisted' to 'blocklisted'.
|
||||
UPDATE subscribers SET status='blocklisted' WHERE status='blacklisted';
|
||||
|
||||
-- Remove the old enum.
|
||||
DROP TYPE subscriber_status CASCADE;
|
||||
|
||||
-- Create new enum with the new values.
|
||||
CREATE TYPE subscriber_status AS ENUM ('enabled', 'disabled', 'blocklisted');
|
||||
|
||||
-- Change the text status column to the new enum.
|
||||
ALTER TABLE subscribers ALTER COLUMN status TYPE subscriber_status
|
||||
USING (status::subscriber_status);
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
_, err := db.Exec(`
|
||||
ALTER TABLE media DROP COLUMN IF EXISTS width,
|
||||
DROP COLUMN IF EXISTS height,
|
||||
ADD COLUMN IF NOT EXISTS provider TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- 'blacklisted' to 'blocklisted' ENUM rename is not possible (until pg10),
|
||||
-- so just add the new value and ignore the old one.
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT NOT NULL UNIQUE,
|
||||
value JSONB NOT NULL DEFAULT '{}',
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_settings_key ON settings(key);
|
||||
|
||||
-- Insert default settings if the table is empty.
|
||||
INSERT INTO settings (key, value) SELECT k, v::JSONB FROM (VALUES
|
||||
('app.root_url', '"http://localhost:9000"'),
|
||||
('app.favicon_url', '""'),
|
||||
('app.from_email', '"eaglecast <noreply@eaglecast.yoursite.com>"'),
|
||||
('app.logo_url', '"http://localhost:9000/public/static/logo.png"'),
|
||||
('app.concurrency', '10'),
|
||||
('app.message_rate', '10'),
|
||||
('app.batch_size', '1000'),
|
||||
('app.max_send_errors', '1000'),
|
||||
('app.notify_emails', '[]'),
|
||||
('privacy.unsubscribe_header', 'true'),
|
||||
('privacy.allow_blocklist', 'true'),
|
||||
('privacy.allow_export', 'true'),
|
||||
('privacy.allow_wipe', 'true'),
|
||||
('privacy.exportable', '["profile", "subscriptions", "campaign_views", "link_clicks"]'),
|
||||
('upload.provider', '"filesystem"'),
|
||||
('upload.filesystem.upload_path', '"uploads"'),
|
||||
('upload.filesystem.upload_uri', '"/uploads"'),
|
||||
('upload.s3.aws_access_key_id', '""'),
|
||||
('upload.s3.aws_secret_access_key', '""'),
|
||||
('upload.s3.aws_default_region', '"ap-south-1"'),
|
||||
('upload.s3.bucket', '""'),
|
||||
('upload.s3.bucket_domain', '""'),
|
||||
('upload.s3.bucket_path', '"/"'),
|
||||
('upload.s3.bucket_type', '"public"'),
|
||||
('upload.s3.expiry', '"14d"'),
|
||||
('smtp',
|
||||
'[{"enabled":true, "host":"smtp.yoursite.com","port":25,"auth_protocol":"cram","username":"username","password":"password","hello_hostname":"","max_conns":10,"idle_timeout":"15s","wait_timeout":"5s","max_msg_retries":2,"tls_enabled":true,"tls_skip_verify":false,"email_headers":[]},
|
||||
{"enabled":false, "host":"smtp2.yoursite.com","port":587,"auth_protocol":"plain","username":"username","password":"password","hello_hostname":"","max_conns":10,"idle_timeout":"15s","wait_timeout":"5s","max_msg_retries":2,"tls_enabled":false,"tls_skip_verify":false,"email_headers":[]}]'),
|
||||
('messengers', '[]')) vals(k, v) WHERE NOT EXISTS(SELECT * FROM settings LIMIT 1);
|
||||
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// `provider` in the media table is a new field. If there's provider config available
|
||||
// and no provider value exists in the media table, set it.
|
||||
prov := ko.String("upload.provider")
|
||||
if prov != "" {
|
||||
if _, err := db.Exec(`UPDATE media SET provider=$1 WHERE provider=''`, prov); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"github.com/knadh/stuffbin"
|
||||
)
|
||||
|
||||
// V0_8_0 performs the DB migrations for v.0.8.0.
|
||||
func V0_8_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
|
||||
_, err := db.Exec(`
|
||||
INSERT INTO settings (key, value) VALUES ('privacy.individual_tracking', 'false')
|
||||
ON CONFLICT DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('messengers', '[]')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Link clicks shouldn't exist if there's no corresponding link.
|
||||
-- links_clicks.link_id should have been NOT NULL originally.
|
||||
DELETE FROM link_clicks WHERE link_id is NULL;
|
||||
ALTER TABLE link_clicks ALTER COLUMN link_id SET NOT NULL;
|
||||
`)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"github.com/knadh/stuffbin"
|
||||
)
|
||||
|
||||
// V0_9_0 performs the DB migrations for v.0.9.0.
|
||||
func V0_9_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO settings (key, value) VALUES
|
||||
('app.lang', '"en"'),
|
||||
('app.message_sliding_window', 'false'),
|
||||
('app.message_sliding_window_duration', '"1h"'),
|
||||
('app.message_sliding_window_rate', '10000'),
|
||||
('app.enable_public_subscription_page', 'true')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Add alternate (plain text) body field on campaigns.
|
||||
ALTER TABLE campaigns ADD COLUMN IF NOT EXISTS altbody TEXT NULL DEFAULT NULL;
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Until this version, the default template during installation was broken!
|
||||
// Check if there's a broken default template and if yes, override it with the
|
||||
// actual one.
|
||||
tplBody, err := fs.Get("/static/email-templates/default.tpl")
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading default e-mail template: %v", err)
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`UPDATE templates SET body=$1 WHERE body=$2`,
|
||||
tplBody.ReadBytes(), `{{ template "content" . }}`); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"github.com/knadh/stuffbin"
|
||||
)
|
||||
|
||||
// V1_0_0 performs the DB migrations for v.1.0.0.
|
||||
func V1_0_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
|
||||
if _, err := db.Exec(`ALTER TYPE content_type ADD VALUE IF NOT EXISTS 'markdown'`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"github.com/knadh/stuffbin"
|
||||
)
|
||||
|
||||
// V2_0_0 performs the DB migrations for v.1.0.0.
|
||||
func V2_0_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
|
||||
if _, err := db.Exec(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'bounce_type') THEN
|
||||
CREATE TYPE bounce_type AS ENUM ('soft', 'hard', 'complaint');
|
||||
END IF;
|
||||
END$$;
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS bounces (
|
||||
id SERIAL PRIMARY KEY,
|
||||
subscriber_id INTEGER NOT NULL REFERENCES subscribers(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
campaign_id INTEGER NULL REFERENCES campaigns(id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
type bounce_type NOT NULL DEFAULT 'hard',
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
meta JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_bounces_sub_id ON bounces(subscriber_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_bounces_camp_id ON bounces(campaign_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_bounces_source ON bounces(source);
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO settings (key, value) VALUES
|
||||
('app.send_optin_confirmation', 'true'),
|
||||
('privacy.domain_blocklist', '[]'),
|
||||
('bounce.enabled', 'false'),
|
||||
('bounce.webhooks_enabled', 'false'),
|
||||
('bounce.count', '2'),
|
||||
('bounce.action', '"blocklist"'),
|
||||
('bounce.ses_enabled', 'false'),
|
||||
('bounce.sendgrid_enabled', 'false'),
|
||||
('bounce.sendgrid_key', '""'),
|
||||
('bounce.mailboxes', '[{"enabled":false, "type": "pop", "host":"pop.yoursite.com","port":995,"auth_protocol":"userpass","username":"username","password":"password","return_path": "bounce@eaglecast.yoursite.com","scan_interval":"15m","tls_enabled":true,"tls_skip_verify":false}]')
|
||||
ON CONFLICT DO NOTHING;`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`ALTER TABLE subscribers DROP COLUMN IF EXISTS campaigns`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_class WHERE relname = 'campaign_views_pkey') THEN
|
||||
ALTER TABLE campaign_views ADD COLUMN IF NOT EXISTS id BIGSERIAL PRIMARY KEY;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_class WHERE relname = 'link_clicks_pkey') THEN
|
||||
ALTER TABLE link_clicks ADD COLUMN IF NOT EXISTS id BIGSERIAL PRIMARY KEY;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_class WHERE relname = 'campaign_lists_pkey') THEN
|
||||
ALTER TABLE campaign_lists ADD COLUMN IF NOT EXISTS id BIGSERIAL PRIMARY KEY;
|
||||
END IF;
|
||||
END$$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_views_date ON campaign_views((TIMEZONE('UTC', created_at)::DATE));
|
||||
CREATE INDEX IF NOT EXISTS idx_clicks_date ON link_clicks((TIMEZONE('UTC', created_at)::DATE));
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// S3 URL i snow a settings field. Prepare S3 URL based on region and bucket.
|
||||
if _, err := db.Exec(`
|
||||
WITH region AS (
|
||||
SELECT value#>>'{}' AS value FROM settings WHERE key='upload.s3.aws_default_region'
|
||||
), s3url AS (
|
||||
SELECT FORMAT('https://s3.%s.amazonaws.com', (SELECT value FROM region)) AS value
|
||||
)
|
||||
|
||||
INSERT INTO settings (key, value) VALUES ('upload.s3.url', TO_JSON((SELECT * FROM s3url))) ON CONFLICT DO NOTHING;`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"github.com/knadh/stuffbin"
|
||||
)
|
||||
|
||||
// V2_1_0 performs the DB migrations for v.2.1.0.
|
||||
func V2_1_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
|
||||
// Insert appearance related settings.
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO settings (key, value) VALUES
|
||||
('appearance.admin.custom_css', '""'),
|
||||
('appearance.admin.custom_js', '""'),
|
||||
('appearance.public.custom_css', '""'),
|
||||
('appearance.public.custom_js', '""'),
|
||||
('upload.s3.public_url', '""')
|
||||
ON CONFLICT DO NOTHING;
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Replace all `tls_enabled: true/false` keys in the `smtp` settings JSON array
|
||||
// with the new field `tls_type: STARTTLS|TLS|none`.
|
||||
// The `tls_enabled` key is removed.
|
||||
if _, err := db.Exec(`
|
||||
UPDATE settings SET value = s.updated
|
||||
FROM (
|
||||
SELECT JSONB_AGG(
|
||||
JSONB_SET(v - 'tls_enabled', '{tls_type}', (CASE WHEN v->>'tls_enabled' = 'true' THEN '"STARTTLS"' ELSE '"none"' END)::JSONB)
|
||||
) AS updated FROM settings, JSONB_ARRAY_ELEMENTS(value) v WHERE key = 'smtp'
|
||||
) s WHERE key = 'smtp' AND value::TEXT LIKE '%tls_enabled%';
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`ALTER TABLE campaigns ADD COLUMN IF NOT EXISTS headers JSONB NOT NULL DEFAULT '[]';`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"github.com/knadh/stuffbin"
|
||||
)
|
||||
|
||||
// V2_2_0 performs the DB migrations for v.2.2.0.
|
||||
func V2_2_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
|
||||
if _, err := db.Exec(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'template_type') THEN
|
||||
CREATE TYPE template_type AS ENUM ('campaign', 'tx');
|
||||
END IF;
|
||||
END$$;
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`ALTER TABLE templates ADD COLUMN IF NOT EXISTS "type" template_type NOT NULL DEFAULT 'campaign'`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`ALTER TABLE templates ADD COLUMN IF NOT EXISTS subject TEXT NOT NULL DEFAULT ''`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(`ALTER TABLE templates ALTER COLUMN subject DROP DEFAULT`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Insert transactional template.
|
||||
txTpl, err := fs.Get("/static/email-templates/sample-tx.tpl")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO templates (name, type, subject, body) VALUES($1, $2, $3, $4)`,
|
||||
"Sample transactional template", "tx", "Welcome {{ .Subscriber.Name }}", txTpl.ReadBytes()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"github.com/knadh/stuffbin"
|
||||
)
|
||||
|
||||
// V2_2_0 performs the DB migrations for v.2.3.0.
|
||||
func V2_3_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
|
||||
if _, err := db.Exec(`ALTER TABLE media ADD COLUMN IF NOT EXISTS "meta" JSONB NOT NULL DEFAULT '{}'`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add `description` field to lists.
|
||||
if _, err := db.Exec(`ALTER TABLE lists ADD COLUMN IF NOT EXISTS "description" TEXT NOT NULL DEFAULT ''`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add archive publishing field to campaigns.
|
||||
if _, err := db.Exec(`ALTER TABLE campaigns
|
||||
ADD COLUMN IF NOT EXISTS archive BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS archive_meta JSONB NOT NULL DEFAULT '{}',
|
||||
ADD COLUMN IF NOT EXISTS archive_template_id INTEGER REFERENCES templates(id) ON DELETE SET DEFAULT DEFAULT 1
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Insert new preference settings.
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO settings (key, value) VALUES
|
||||
('app.site_name', '"Mailing list"'),
|
||||
('app.enable_public_archive', 'true'),
|
||||
('privacy.allow_preferences', 'false')
|
||||
ON CONFLICT DO NOTHING;
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"github.com/knadh/stuffbin"
|
||||
)
|
||||
|
||||
// V2_4_0 performs the DB migrations.
|
||||
func V2_4_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
|
||||
// Insert new preference settings.
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO settings (key, value) VALUES
|
||||
('security.enable_captcha', 'false'),
|
||||
('security.captcha_key', '""'),
|
||||
('security.captcha_secret', '""')
|
||||
ON CONFLICT DO NOTHING;
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"github.com/knadh/stuffbin"
|
||||
)
|
||||
|
||||
// V2_5_0 performs the DB migrations.
|
||||
func V2_5_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
|
||||
// Insert new preference settings.
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO settings (key, value) VALUES
|
||||
('upload.extensions', '["jpg","jpeg","png","gif","svg","*"]'),
|
||||
('app.enable_public_archive_rss_content', 'false'),
|
||||
('bounce.actions', '{"soft": {"count": 2, "action": "none"}, "hard": {"count": 2, "action": "blocklist"}, "complaint" : {"count": 2, "action": "blocklist"}}'),
|
||||
('privacy.record_optin_ip', 'false')
|
||||
ON CONFLICT DO NOTHING;
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`
|
||||
DELETE FROM settings WHERE key IN ('bounce.count', 'bounce.action');
|
||||
|
||||
-- Add the content_type column.
|
||||
ALTER TABLE media ADD COLUMN IF NOT EXISTS content_type TEXT NOT NULL DEFAULT 'application/octet-stream';
|
||||
|
||||
-- Add meta column to subscriptions.
|
||||
ALTER TABLE subscriber_lists ADD COLUMN IF NOT EXISTS meta JSONB NOT NULL DEFAULT '{}';
|
||||
|
||||
-- Fill the content type column for existing files (which would only be images at this point).
|
||||
UPDATE media SET content_type = CASE
|
||||
WHEN LOWER(SUBSTRING(filename FROM '.([^.]+)$')) = 'svg' THEN 'image/svg+xml'
|
||||
ELSE 'image/' || LOWER(SUBSTRING(filename FROM '.([^.]+)$'))
|
||||
END;
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS campaign_media (
|
||||
campaign_id INTEGER REFERENCES campaigns(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
|
||||
-- Media items may be deleted, so media_id is nullable
|
||||
-- and a copy of the original name is maintained here.
|
||||
media_id INTEGER NULL REFERENCES media(id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
|
||||
filename TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_camp_media_id ON campaign_media (campaign_id, media_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_camp_media_camp_id ON campaign_media(campaign_id);
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"github.com/knadh/stuffbin"
|
||||
)
|
||||
|
||||
// V3_0_0 performs the DB migrations.
|
||||
func V3_0_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
|
||||
lo.Println("IMPORTANT: this upgrade might take a while if you have a large database. Please be patient ...")
|
||||
|
||||
// Insert new preference settings.
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO settings (key, value) VALUES
|
||||
('bounce.postmark', '{"enabled": false, "username": "", "password": ""}'),
|
||||
('app.cache_slow_queries', 'false'),
|
||||
('app.cache_slow_queries_interval', '"0 3 * * *"')
|
||||
ON CONFLICT DO NOTHING;
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Fix incorrect "d" (day) time prefix in S3 expiry settings.
|
||||
if _, err := db.Exec(`UPDATE settings SET value = '"167h"' WHERE key = 'upload.s3.expiry' AND value = '"14d"'`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`ALTER TABLE campaigns ADD COLUMN IF NOT EXISTS archive_slug TEXT NULL UNIQUE`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add indexes that make sorting faster on large tables.
|
||||
if _, err := db.Exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_subs_created_at ON subscribers(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_subs_updated_at ON subscribers(updated_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_camps_status ON campaigns(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_camps_name ON campaigns(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_camps_created_at ON campaigns(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_camps_updated_at ON campaigns(updated_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_lists_type ON lists(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_lists_optin ON lists(optin);
|
||||
CREATE INDEX IF NOT EXISTS idx_lists_name ON lists(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_lists_created_at ON lists(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_lists_updated_at ON lists(updated_at);
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create materialized views for slow aggregate queries.
|
||||
if _, err := db.Exec(`
|
||||
-- dashboard stats
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS mat_dashboard_counts AS
|
||||
WITH subs AS (
|
||||
SELECT COUNT(*) AS num, status FROM subscribers GROUP BY status
|
||||
)
|
||||
SELECT NOW() AS updated_at,
|
||||
JSON_BUILD_OBJECT(
|
||||
'subscribers', JSON_BUILD_OBJECT(
|
||||
'total', (SELECT SUM(num) FROM subs),
|
||||
'blocklisted', (SELECT num FROM subs WHERE status='blocklisted'),
|
||||
'orphans', (
|
||||
SELECT COUNT(id) FROM subscribers
|
||||
LEFT JOIN subscriber_lists ON (subscribers.id = subscriber_lists.subscriber_id)
|
||||
WHERE subscriber_lists.subscriber_id IS NULL
|
||||
)
|
||||
),
|
||||
'lists', JSON_BUILD_OBJECT(
|
||||
'total', (SELECT COUNT(*) FROM lists),
|
||||
'private', (SELECT COUNT(*) FROM lists WHERE type='private'),
|
||||
'public', (SELECT COUNT(*) FROM lists WHERE type='public'),
|
||||
'optin_single', (SELECT COUNT(*) FROM lists WHERE optin='single'),
|
||||
'optin_double', (SELECT COUNT(*) FROM lists WHERE optin='double')
|
||||
),
|
||||
'campaigns', JSON_BUILD_OBJECT(
|
||||
'total', (SELECT COUNT(*) FROM campaigns),
|
||||
'by_status', (
|
||||
SELECT JSON_OBJECT_AGG (status, num) FROM
|
||||
(SELECT status, COUNT(*) AS num FROM campaigns GROUP BY status) r
|
||||
)
|
||||
),
|
||||
'messages', (SELECT SUM(sent) AS messages FROM campaigns)
|
||||
) AS data;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS mat_dashboard_stats_idx ON mat_dashboard_counts (updated_at);
|
||||
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS mat_dashboard_charts AS
|
||||
WITH clicks AS (
|
||||
SELECT JSON_AGG(ROW_TO_JSON(row))
|
||||
FROM (
|
||||
WITH viewDates AS (
|
||||
SELECT TIMEZONE('UTC', created_at)::DATE AS to_date,
|
||||
TIMEZONE('UTC', created_at)::DATE - INTERVAL '30 DAY' AS from_date
|
||||
FROM link_clicks ORDER BY id DESC LIMIT 1
|
||||
)
|
||||
SELECT COUNT(*) AS count, created_at::DATE as date FROM link_clicks
|
||||
-- use > between < to force the use of the date index.
|
||||
WHERE TIMEZONE('UTC', created_at)::DATE BETWEEN (SELECT from_date FROM viewDates) AND (SELECT to_date FROM viewDates)
|
||||
GROUP by date ORDER BY date
|
||||
) row
|
||||
),
|
||||
views AS (
|
||||
SELECT JSON_AGG(ROW_TO_JSON(row))
|
||||
FROM (
|
||||
WITH viewDates AS (
|
||||
SELECT TIMEZONE('UTC', created_at)::DATE AS to_date,
|
||||
TIMEZONE('UTC', created_at)::DATE - INTERVAL '30 DAY' AS from_date
|
||||
FROM campaign_views ORDER BY id DESC LIMIT 1
|
||||
)
|
||||
SELECT COUNT(*) AS count, created_at::DATE as date FROM campaign_views
|
||||
-- use > between < to force the use of the date index.
|
||||
WHERE TIMEZONE('UTC', created_at)::DATE BETWEEN (SELECT from_date FROM viewDates) AND (SELECT to_date FROM viewDates)
|
||||
GROUP by date ORDER BY date
|
||||
) row
|
||||
)
|
||||
SELECT NOW() AS updated_at, JSON_BUILD_OBJECT('link_clicks', COALESCE((SELECT * FROM clicks), '[]'),
|
||||
'campaign_views', COALESCE((SELECT * FROM views), '[]')
|
||||
) AS data;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS mat_dashboard_charts_idx ON mat_dashboard_charts (updated_at);
|
||||
|
||||
-- subscriber counts stats for lists
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS mat_list_subscriber_stats AS
|
||||
SELECT NOW() AS updated_at, lists.id AS list_id, subscriber_lists.status, COUNT(*) AS subscriber_count FROM lists
|
||||
LEFT JOIN subscriber_lists ON (subscriber_lists.list_id = lists.id)
|
||||
GROUP BY lists.id, subscriber_lists.status
|
||||
UNION ALL
|
||||
SELECT NOW() AS updated_at, 0 AS list_id, NULL AS status, COUNT(*) AS subscriber_count FROM subscribers;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS mat_list_subscriber_stats_idx ON mat_list_subscriber_stats (list_id, status);
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"github.com/knadh/stuffbin"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// V4_0_0 performs the DB migrations.
|
||||
func V4_0_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
|
||||
lo.Println("IMPORTANT: this upgrade might take a while if you have a large database. Please be patient ...")
|
||||
|
||||
if _, err := db.Exec(`CREATE INDEX IF NOT EXISTS idx_subs_id_status ON subscribers(id, status);`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'user_type') THEN
|
||||
CREATE TYPE user_type AS ENUM ('user', 'api');
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'user_status') THEN
|
||||
CREATE TYPE user_status AS ENUM ('enabled', 'disabled');
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'role_type') THEN
|
||||
CREATE TYPE role_type AS ENUM ('user', 'list');
|
||||
END IF;
|
||||
END$$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS roles (
|
||||
id SERIAL PRIMARY KEY,
|
||||
type role_type NOT NULL DEFAULT 'user',
|
||||
parent_id INTEGER NULL REFERENCES roles(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
list_id INTEGER NULL REFERENCES lists(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
permissions TEXT[] NOT NULL DEFAULT '{}',
|
||||
name TEXT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_roles ON roles (parent_id, list_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_roles_name ON roles (type, name) WHERE name IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_login BOOLEAN NOT NULL DEFAULT false,
|
||||
password TEXT NULL,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
avatar TEXT NULL,
|
||||
type user_type NOT NULL DEFAULT 'user',
|
||||
user_role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE RESTRICT,
|
||||
list_role_id INTEGER NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||
status user_status NOT NULL DEFAULT 'disabled',
|
||||
loggedin_at TIMESTAMP WITH TIME ZONE NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
data JSONB DEFAULT '{}'::jsonb NOT NULL,
|
||||
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now() NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions ON sessions (id, created_at);
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Insert new preference settings.
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO settings (key, value) VALUES('security.oidc', '{"enabled": false, "provider_url": "", "client_id": "", "client_secret": ""}') ON CONFLICT DO NOTHING;
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Insert superuser role.
|
||||
pmRaw, err := fs.Read("/permissions.json")
|
||||
if err != nil {
|
||||
lo.Fatalf("error reading permissions file: %v", err)
|
||||
}
|
||||
permGroups := []struct {
|
||||
Group string `json:"group"`
|
||||
Permissions []string `json:"permissions"`
|
||||
}{}
|
||||
if err := json.Unmarshal(pmRaw, &permGroups); err != nil {
|
||||
lo.Fatalf("error loading permissions file: %v", err)
|
||||
}
|
||||
|
||||
// Create super admin.
|
||||
var (
|
||||
user = os.Getenv("EAGLECAST_ADMIN_USER")
|
||||
password = os.Getenv("EAGLECAST_ADMIN_PASSWORD")
|
||||
typ = "env"
|
||||
)
|
||||
|
||||
if user != "" {
|
||||
// If the env vars are set, use those values
|
||||
if len(user) < 2 || len(password) < 8 {
|
||||
lo.Fatal("EAGLECAST_ADMIN_USER should be min 3 chars and EAGLECAST_ADMIN_PASSWORD should be min 8 chars")
|
||||
}
|
||||
} else if ko.Exists("app.admin_username") {
|
||||
// Legacy admin/password are set in the config or env var. Use those.
|
||||
user = ko.String("app.admin_username")
|
||||
password = ko.String("app.admin_password")
|
||||
|
||||
if len(user) < 2 || len(password) < 8 {
|
||||
lo.Fatal("admin_username should be min 3 chars and admin_password should be min 8 chars in the TOML config")
|
||||
}
|
||||
typ = "TOML config"
|
||||
}
|
||||
|
||||
if user != "" && password != "" {
|
||||
lo.Printf("creating admin user '%s'. Credential source is '%s'", user, typ)
|
||||
|
||||
perms := []string{}
|
||||
for _, group := range permGroups {
|
||||
perms = append(perms, group.Permissions...)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO roles (type, name, permissions) VALUES('user', 'Super Admin', $1) ON CONFLICT DO NOTHING`, pq.Array(perms)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO users (username, password_login, password, email, name, type, user_role_id, status) VALUES($1, true, CRYPT($2, GEN_SALT('bf')), $3, $4, 'user', 1, 'enabled') ON CONFLICT DO NOTHING;
|
||||
`, user, password, user+"@eaglecast", user); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
lo.Printf("no Super Admin user created. Visit webpage to create user.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"github.com/knadh/stuffbin"
|
||||
)
|
||||
|
||||
// V4_1_0 performs the DB migrations.
|
||||
func V4_1_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
|
||||
// Insert new preference settings.
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO settings (key, value) VALUES('bounce.forwardemail', '{"enabled": false, "key": ""}') ON CONFLICT DO NOTHING;
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"github.com/knadh/stuffbin"
|
||||
)
|
||||
|
||||
// V5_0_0 performs the DB migrations.
|
||||
func V5_0_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
|
||||
lo.Println("IMPORTANT: this upgrade might take a while if you have a large database. Please be patient ...")
|
||||
if _, err := db.Exec(`
|
||||
-- Create a new temp materialized view with the fixed query (removing COUNT(*) that returns 1 for NULLs)
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS mat_list_subscriber_stats_v5_0_0 AS
|
||||
SELECT NOW() AS updated_at, lists.id AS list_id, subscriber_lists.status, COUNT(subscriber_lists.status) AS subscriber_count FROM lists
|
||||
LEFT JOIN subscriber_lists ON (subscriber_lists.list_id = lists.id)
|
||||
GROUP BY lists.id, subscriber_lists.status
|
||||
UNION ALL
|
||||
SELECT NOW() AS updated_at, 0 AS list_id, NULL AS status, COUNT(id) AS subscriber_count FROM subscribers;
|
||||
|
||||
-- Drop the old view and index.
|
||||
DROP INDEX IF EXISTS mat_list_subscriber_stats_idx;
|
||||
DROP MATERIALIZED VIEW IF EXISTS mat_list_subscriber_stats;
|
||||
|
||||
-- Rename the temp view and create an index.
|
||||
ALTER MATERIALIZED VIEW mat_list_subscriber_stats_v5_0_0 RENAME TO mat_list_subscriber_stats;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS mat_list_subscriber_stats_idx ON mat_list_subscriber_stats (list_id, status);
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Index of media filename lookup.
|
||||
if _, err := db.Exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_media_filename ON media(provider, filename);
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Insert new preference settings.
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO settings (key, value) VALUES
|
||||
('privacy.domain_allowlist', '[]'),
|
||||
('security.oidc.provider_name', '""')
|
||||
ON CONFLICT DO NOTHING;
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Insert new default super admin permissions.
|
||||
if _, err := db.Exec(`
|
||||
UPDATE roles SET permissions = permissions || '{campaigns:get_all}' WHERE id = 1 AND NOT permissions @> '{campaigns:get_all}';
|
||||
UPDATE roles SET permissions = permissions || '{campaigns:manage_all}' WHERE id = 1 AND NOT permissions @> '{campaigns:manage_all}';
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Visual editor changes.
|
||||
if _, err := db.Exec(`
|
||||
ALTER TYPE content_type ADD VALUE IF NOT EXISTS 'visual';
|
||||
ALTER TYPE template_type ADD VALUE IF NOT EXISTS 'campaign_visual';
|
||||
ALTER TABLE templates ADD COLUMN IF NOT EXISTS body_source TEXT NULL;
|
||||
ALTER TABLE campaigns ADD COLUMN IF NOT EXISTS body_source TEXT NULL;
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(`
|
||||
ALTER TABLE campaigns DROP CONSTRAINT IF EXISTS campaigns_template_id_fkey,
|
||||
ADD FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE SET NULL,
|
||||
ALTER COLUMN template_id DROP DEFAULT;
|
||||
|
||||
ALTER TABLE campaigns DROP CONSTRAINT IF EXISTS campaigns_archive_template_id_fkey,
|
||||
ADD FOREIGN KEY (archive_template_id) REFERENCES templates(id) ON DELETE SET NULL,
|
||||
ALTER COLUMN archive_template_id DROP DEFAULT;
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Insert visual campaign template.
|
||||
visualTpl, err := fs.Get("/static/email-templates/default-visual.tpl")
|
||||
if err != nil {
|
||||
lo.Fatalf("error reading default visual template: %v", err)
|
||||
}
|
||||
visualSrc, err := fs.Get("/static/email-templates/default-visual.json")
|
||||
if err != nil {
|
||||
lo.Fatalf("error reading default visual template json: %v", err)
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`INSERT INTO templates (name, type, subject, body, body_source) VALUES($1, $2, $3, $4, $5)`,
|
||||
"Sample visual template", "campaign_visual", "", visualTpl.ReadBytes(), visualSrc.ReadBytes()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"github.com/knadh/stuffbin"
|
||||
)
|
||||
|
||||
func V5_1_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
|
||||
// Update OIDC settings to include auto_create_users and default_user_role_id fields if not present
|
||||
_, err := db.Exec(`
|
||||
UPDATE settings
|
||||
SET value = value::JSONB
|
||||
|| CASE WHEN NOT (value::JSONB ? 'auto_create_users') THEN '{"auto_create_users": false}'::JSONB ELSE '{}'::JSONB END
|
||||
|| CASE WHEN NOT (value::JSONB ? 'default_user_role_id') THEN '{"default_user_role_id": null}'::JSONB ELSE '{}'::JSONB END
|
||||
|| CASE WHEN NOT (value::JSONB ? 'default_list_role_id') THEN '{"default_list_role_id": null}'::JSONB ELSE '{}'::JSONB END
|
||||
WHERE key = 'security.oidc';
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Migrate old captcha settings to new JSON structure.
|
||||
_, err = db.Exec(`
|
||||
WITH old AS (
|
||||
SELECT
|
||||
COALESCE((SELECT (value#>>'{}')::BOOLEAN FROM settings WHERE key = 'security.enable_captcha'), false) AS enable_captcha,
|
||||
COALESCE((SELECT value#>>'{}' FROM settings WHERE key = 'security.captcha_key'), '') AS captcha_key,
|
||||
COALESCE((SELECT value#>>'{}' FROM settings WHERE key = 'security.captcha_secret'), '') AS captcha_secret
|
||||
)
|
||||
INSERT INTO settings (key, value, updated_at)
|
||||
SELECT
|
||||
'security.captcha',
|
||||
JSON_BUILD_OBJECT(
|
||||
'altcha', JSON_BUILD_OBJECT('enabled', false, 'complexity', 300000),
|
||||
'hcaptcha', JSON_BUILD_OBJECT('enabled', enable_captcha, 'key', captcha_key, 'secret', captcha_secret)
|
||||
),
|
||||
NOW()
|
||||
FROM old
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove old captcha settings.
|
||||
if _, err = db.Exec(`DELETE FROM settings WHERE key IN ('security.enable_captcha', 'security.captcha_key', 'security.captcha_secret')`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add maintenance.db setting if not present.
|
||||
_, err = db.Exec(`
|
||||
INSERT INTO settings (key, value, updated_at)
|
||||
VALUES ('maintenance.db', '{"vacuum": false, "vacuum_cron_interval": "0 2 * * *"}', NOW())
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"github.com/knadh/stuffbin"
|
||||
)
|
||||
|
||||
func V6_0_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
|
||||
_, err := db.Exec(`
|
||||
INSERT INTO settings (key, value, updated_at) VALUES ('security.cors_origins', '[]', NOW()) ON CONFLICT (key) DO NOTHING
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add 2FA fields to users table.
|
||||
_, err = db.Exec(`
|
||||
DO $$ BEGIN
|
||||
-- Create twofa_type enum if it doesn't exist
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'twofa_type') THEN
|
||||
CREATE TYPE twofa_type AS ENUM ('none', 'totp');
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS twofa_type twofa_type NOT NULL DEFAULT 'none';
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS twofa_key TEXT NULL;
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add status field to lists table.
|
||||
_, err = db.Exec(`
|
||||
DO $$ BEGIN
|
||||
-- Create list_status enum if it doesn't exist
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'list_status') THEN
|
||||
CREATE TYPE list_status AS ENUM ('active', 'archived');
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE lists ADD COLUMN IF NOT EXISTS status list_status NOT NULL DEFAULT 'active';
|
||||
CREATE INDEX IF NOT EXISTS idx_lists_status ON lists(status);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add attribs field to campaigns table.
|
||||
_, err = db.Exec(`ALTER TABLE campaigns ADD COLUMN IF NOT EXISTS attribs JSONB NOT NULL DEFAULT '{}'`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"github.com/knadh/stuffbin"
|
||||
)
|
||||
|
||||
func V6_1_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO settings (key, value, updated_at) VALUES ('privacy.disable_tracking', 'false', NOW()) ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES('bounce.lettermint', '{"enabled": false, "key": ""}') ON CONFLICT DO NOTHING;
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Drop the old UTC-based date indexes and simply use local time with zone consistent
|
||||
// with the rest of the schema.
|
||||
if _, err := db.Exec(`
|
||||
DROP INDEX IF EXISTS idx_views_date; CREATE INDEX IF NOT EXISTS idx_views_date ON campaign_views(created_at);
|
||||
DROP INDEX IF EXISTS idx_clicks_date; CREATE INDEX IF NOT EXISTS idx_clicks_date ON link_clicks(created_at);
|
||||
DROP INDEX IF EXISTS idx_bounces_date; CREATE INDEX IF NOT EXISTS idx_bounces_date ON bounces(created_at);
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Recreate the materialized views to use server local time instead of UTC.
|
||||
// Create new views first, let them populate, then drop the old ones and rename the new ones.
|
||||
lo.Println("IMPORTANT: recreating analytics materialized views. This might take a while if you have a large database. Please be patient ...")
|
||||
if _, err := db.Exec(`
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS mat_dashboard_charts_v6_1_0 AS
|
||||
WITH clicks AS (
|
||||
SELECT JSON_AGG(ROW_TO_JSON(row))
|
||||
FROM (
|
||||
WITH viewDates AS (
|
||||
SELECT created_at::DATE AS to_date,
|
||||
created_at::DATE - INTERVAL '30 DAY' AS from_date
|
||||
FROM link_clicks ORDER BY id DESC LIMIT 1
|
||||
)
|
||||
SELECT COUNT(*) AS count, created_at::DATE as date FROM link_clicks
|
||||
WHERE created_at >= (SELECT from_date FROM viewDates)
|
||||
AND created_at < (SELECT to_date FROM viewDates) + INTERVAL '1 day'
|
||||
GROUP by date ORDER BY date
|
||||
) row
|
||||
),
|
||||
views AS (
|
||||
SELECT JSON_AGG(ROW_TO_JSON(row))
|
||||
FROM (
|
||||
WITH viewDates AS (
|
||||
SELECT created_at::DATE AS to_date,
|
||||
created_at::DATE - INTERVAL '30 DAY' AS from_date
|
||||
FROM campaign_views ORDER BY id DESC LIMIT 1
|
||||
)
|
||||
SELECT COUNT(*) AS count, created_at::DATE as date FROM campaign_views
|
||||
WHERE created_at >= (SELECT from_date FROM viewDates)
|
||||
AND created_at < (SELECT to_date FROM viewDates) + INTERVAL '1 day'
|
||||
GROUP by date ORDER BY date
|
||||
) row
|
||||
)
|
||||
SELECT NOW() AS updated_at, JSON_BUILD_OBJECT('link_clicks', COALESCE((SELECT * FROM clicks), '[]'),
|
||||
'campaign_views', COALESCE((SELECT * FROM views), '[]')
|
||||
) AS data;
|
||||
|
||||
DROP INDEX IF EXISTS mat_dashboard_charts_idx;
|
||||
DROP MATERIALIZED VIEW IF EXISTS mat_dashboard_charts;
|
||||
|
||||
ALTER MATERIALIZED VIEW mat_dashboard_charts_v6_1_0 RENAME TO mat_dashboard_charts;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS mat_dashboard_charts_idx ON mat_dashboard_charts (updated_at);
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Grant 'campaigns:send' to all roles that previously had 'campaigns:manage' or 'campaigns:manage_all'
|
||||
// for backwards compatibility. 'campaigns:send' is a new separate permission.
|
||||
if _, err := db.Exec(`
|
||||
UPDATE roles SET permissions = permissions || '{campaigns:send}'
|
||||
WHERE (permissions @> '{campaigns:manage}' OR permissions @> '{campaigns:manage_all}')
|
||||
AND NOT permissions @> '{campaigns:send}';
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"github.com/knadh/stuffbin"
|
||||
)
|
||||
|
||||
func V6_2_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
|
||||
// Add `msg_retry_delay` and `from_addresses` to each SMTP server entry in the `smtp`
|
||||
// Idempotent: only updates rows where at least one entry is missing the key.
|
||||
if _, err := db.Exec(`
|
||||
UPDATE settings
|
||||
SET value = (
|
||||
SELECT JSONB_AGG(
|
||||
JSONB_SET(
|
||||
JSONB_SET(
|
||||
v,
|
||||
'{msg_retry_delay}',
|
||||
COALESCE(v->'msg_retry_delay', '"10ms"'::JSONB)
|
||||
),
|
||||
'{from_addresses}',
|
||||
COALESCE(v->'from_addresses', '[]'::JSONB)
|
||||
)
|
||||
ORDER BY ord
|
||||
)
|
||||
FROM JSONB_ARRAY_ELEMENTS(value) WITH ORDINALITY AS t(v, ord)
|
||||
)
|
||||
WHERE key = 'smtp'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM JSONB_ARRAY_ELEMENTS(value) AS v
|
||||
WHERE NOT (v ? 'msg_retry_delay') OR NOT (v ? 'from_addresses')
|
||||
);
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update app language settings that used incorrect locale codes.
|
||||
if _, err := db.Exec(`
|
||||
UPDATE settings SET value = langs.new_value
|
||||
FROM (VALUES
|
||||
('"cs-cz"'::JSONB, '"cs"'::JSONB),
|
||||
('"jp"'::JSONB, '"ja"'::JSONB),
|
||||
('"se"'::JSONB, '"sv"'::JSONB)
|
||||
) AS langs(old_value, new_value)
|
||||
WHERE key = 'app.lang' AND value = langs.old_value;
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add `bounce.azure` for ACS/Event Grid bounce handling; upsert if the row already exists.
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO settings (key, value) VALUES('bounce.azure', '{"enabled": false, "shared_secret": "", "shared_secret_header": ""}')
|
||||
ON CONFLICT (key) DO UPDATE
|
||||
SET value = jsonb_build_object(
|
||||
'enabled', COALESCE((settings.value->>'enabled')::boolean, false),
|
||||
'shared_secret', COALESCE(settings.value->>'shared_secret', ''),
|
||||
'shared_secret_header', COALESCE(settings.value->>'shared_secret_header', '')
|
||||
);
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`INSERT INTO settings (key, value) VALUES ('app.show_optin_page', 'true') ON CONFLICT (key) DO NOTHING `); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Rename `security.cors_origins` to `security.trusted_urls`.
|
||||
if _, err := db.Exec(`UPDATE settings SET key = 'security.trusted_urls'
|
||||
WHERE key = 'security.cors_origins'
|
||||
AND NOT EXISTS (SELECT 1 FROM settings WHERE key = 'security.trusted_urls')`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Hash existing API tokens. This is idempotent by skipping values that
|
||||
// already look like lowercase SHA-256 hex digests.
|
||||
if _, err := db.Exec(`
|
||||
UPDATE users
|
||||
SET password = ENCODE(DIGEST(password, 'sha256'), 'hex')
|
||||
WHERE type = 'api'
|
||||
AND password IS NOT NULL
|
||||
AND password != ''
|
||||
AND password !~ '^[a-f0-9]{64}$';
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// package notifs is a special singleton, stateful globally accessible package
|
||||
// that handles sending out arbitrary notifications to the admin and users.
|
||||
// It's initialized once in the main package and is accessed globally across
|
||||
// other packages.
|
||||
package notifs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/textproto"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"source.offmarket.win/aleagle/eaglecast/internal/messenger/email"
|
||||
"source.offmarket.win/aleagle/eaglecast/models"
|
||||
)
|
||||
|
||||
const (
|
||||
TplImport = "import-status"
|
||||
TplCampaignStatus = "campaign-status"
|
||||
TplSubscriberOptin = "subscriber-optin"
|
||||
TplSubscriberData = "subscriber-data"
|
||||
TplForgotPassword = "forgot-password"
|
||||
)
|
||||
|
||||
type FuncPush func(msg models.Message) error
|
||||
type FuncNotif func(toEmails []string, subject, tplName string, data any, headers textproto.MIMEHeader) error
|
||||
type FuncNotifSystem func(subject, tplName string, data any, headers textproto.MIMEHeader) error
|
||||
|
||||
type Opt struct {
|
||||
FromEmail string
|
||||
SystemEmails []string
|
||||
ContentType string
|
||||
}
|
||||
|
||||
type Notifs struct {
|
||||
em *email.Emailer
|
||||
lo *log.Logger
|
||||
|
||||
opt Opt
|
||||
}
|
||||
|
||||
var (
|
||||
reTitle = regexp.MustCompile(`(?s)<title\s*data-i18n\s*>(.+?)</title>`)
|
||||
|
||||
Tpls *template.Template
|
||||
no *Notifs
|
||||
)
|
||||
|
||||
// Initialize returns a new Notifs instance.
|
||||
func Initialize(opt Opt, tpls *template.Template, em *email.Emailer, lo *log.Logger) {
|
||||
if no != nil {
|
||||
lo.Fatal("notifs already initialized")
|
||||
}
|
||||
|
||||
Tpls = tpls
|
||||
no = &Notifs{
|
||||
opt: opt,
|
||||
em: em,
|
||||
lo: lo,
|
||||
}
|
||||
}
|
||||
|
||||
// NotifySystem sends out an e-mail notification to the admin emails.
|
||||
func NotifySystem(subject, tplName string, data any, hdr textproto.MIMEHeader) error {
|
||||
return Notify(no.opt.SystemEmails, subject, tplName, data, hdr)
|
||||
}
|
||||
|
||||
// Notify sends out an e-mail notification.
|
||||
func Notify(toEmails []string, subject, tplName string, data any, hdr textproto.MIMEHeader) error {
|
||||
if len(toEmails) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := Tpls.ExecuteTemplate(&buf, tplName, data); err != nil {
|
||||
no.lo.Printf("error compiling notification template '%s': %v", tplName, err)
|
||||
return err
|
||||
}
|
||||
body := buf.Bytes()
|
||||
|
||||
subject, body = GetTplSubject(subject, body)
|
||||
|
||||
m := models.Message{
|
||||
Messenger: "email",
|
||||
ContentType: no.opt.ContentType,
|
||||
From: no.opt.FromEmail,
|
||||
To: toEmails,
|
||||
Subject: subject,
|
||||
Body: body,
|
||||
Headers: hdr,
|
||||
}
|
||||
|
||||
// Send the message.
|
||||
if err := no.em.Push(m); err != nil {
|
||||
no.lo.Printf("error sending admin notification (%s): %v", subject, err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetTplSubject extracts any custom i18n subject rendered in the given rendered
|
||||
// template body. If it's not found, the incoming subject and body are returned.
|
||||
func GetTplSubject(subject string, body []byte) (string, []byte) {
|
||||
m := reTitle.FindSubmatch(body)
|
||||
if len(m) != 2 {
|
||||
return subject, body
|
||||
}
|
||||
|
||||
return strings.TrimSpace(string(m[1])), reTitle.ReplaceAll(body, []byte(""))
|
||||
}
|
||||
@@ -0,0 +1,765 @@
|
||||
// Package subimporter implements a bulk ZIP/CSV importer of subscribers.
|
||||
// It implements a simple queue for buffering imports and committing records
|
||||
// to DB along with ZIP and CSV handling utilities. It is meant to be used as
|
||||
// a singleton as each Importer instance is stateful, where it keeps track of
|
||||
// an import in progress. Only one import should happen on a single importer
|
||||
// instance at a time.
|
||||
package subimporter
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/gofrs/uuid/v5"
|
||||
"source.offmarket.win/aleagle/eaglecast/internal/i18n"
|
||||
"source.offmarket.win/aleagle/eaglecast/internal/utils"
|
||||
"source.offmarket.win/aleagle/eaglecast/models"
|
||||
"github.com/lib/pq"
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
const (
|
||||
// commitBatchSize is the number of inserts to commit in a single SQL transaction.
|
||||
commitBatchSize = 10000
|
||||
)
|
||||
|
||||
// Various import statuses.
|
||||
const (
|
||||
StatusNone = "none"
|
||||
StatusImporting = "importing"
|
||||
StatusStopping = "stopping"
|
||||
StatusFinished = "finished"
|
||||
StatusFailed = "failed"
|
||||
|
||||
ModeSubscribe = "subscribe"
|
||||
ModeBlocklist = "blocklist"
|
||||
)
|
||||
|
||||
// Importer represents the bulk CSV subscriber import system.
|
||||
type Importer struct {
|
||||
opt Options
|
||||
db *sql.DB
|
||||
i18n *i18n.I18n
|
||||
|
||||
domainBlocklist map[string]struct{}
|
||||
hasBlocklistWildcards bool
|
||||
hasBlocklist bool
|
||||
|
||||
domainAllowlist map[string]struct{}
|
||||
hasAllowlistWildcards bool
|
||||
hasAllowlist bool
|
||||
|
||||
stop chan bool
|
||||
status Status
|
||||
sync.RWMutex
|
||||
}
|
||||
|
||||
// Options represents import options.
|
||||
type Options struct {
|
||||
UpsertStmt *sql.Stmt
|
||||
BlocklistStmt *sql.Stmt
|
||||
UpdateListDateStmt *sql.Stmt
|
||||
PostCB func(subject string, data any) error
|
||||
|
||||
DomainBlocklist []string
|
||||
DomainAllowlist []string
|
||||
}
|
||||
|
||||
// Session represents a single import session.
|
||||
type Session struct {
|
||||
im *Importer
|
||||
subQueue chan SubReq
|
||||
log *log.Logger
|
||||
|
||||
opt SessionOpt
|
||||
}
|
||||
|
||||
// SessionOpt represents the options for an importer session.
|
||||
type SessionOpt struct {
|
||||
Filename string `json:"filename"`
|
||||
Mode string `json:"mode"`
|
||||
SubStatus string `json:"subscription_status"`
|
||||
Overwrite bool `json:"overwrite"`
|
||||
OverwriteUserInfo bool `json:"overwrite_userinfo"`
|
||||
OverwriteSubStatus bool `json:"overwrite_subscription_status"`
|
||||
Delim string `json:"delim"`
|
||||
ListIDs []int `json:"lists"`
|
||||
}
|
||||
|
||||
// Status represents statistics from an ongoing import session.
|
||||
type Status struct {
|
||||
Name string `json:"name"`
|
||||
Total int `json:"total"`
|
||||
Imported int `json:"imported"`
|
||||
Status string `json:"status"`
|
||||
logBuf *bytes.Buffer
|
||||
}
|
||||
|
||||
// SubReq is a wrapper over the Subscriber model.
|
||||
type SubReq struct {
|
||||
models.Subscriber
|
||||
Lists []int `json:"lists"`
|
||||
ListUUIDs []string `json:"list_uuids"`
|
||||
PreconfirmSubs bool `json:"preconfirm_subscriptions"`
|
||||
}
|
||||
|
||||
type importStatusTpl struct {
|
||||
Name string
|
||||
Status string
|
||||
Imported int
|
||||
Total int
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrIsImporting is thrown when an import request is made while an
|
||||
// import is already running.
|
||||
ErrIsImporting = errors.New("import is already running")
|
||||
|
||||
csvHeaders = map[string]bool{
|
||||
"email": true,
|
||||
"name": true,
|
||||
"attributes": true}
|
||||
|
||||
regexCleanStr = regexp.MustCompile("[[:^ascii:]]")
|
||||
)
|
||||
|
||||
// New returns a new instance of Importer.
|
||||
func New(opt Options, db *sql.DB, i *i18n.I18n) *Importer {
|
||||
im := Importer{
|
||||
opt: opt,
|
||||
db: db,
|
||||
i18n: i,
|
||||
domainBlocklist: make(map[string]struct{}, len(opt.DomainBlocklist)),
|
||||
domainAllowlist: make(map[string]struct{}, len(opt.DomainAllowlist)),
|
||||
status: Status{Status: StatusNone, logBuf: bytes.NewBuffer(nil)},
|
||||
stop: make(chan bool, 1),
|
||||
}
|
||||
|
||||
// Domain blocklist.
|
||||
mp, hasWildcards := makeDomainMap(opt.DomainBlocklist)
|
||||
im.domainBlocklist = mp
|
||||
im.hasBlocklistWildcards = hasWildcards
|
||||
im.hasBlocklist = len(mp) > 0
|
||||
|
||||
// Domain allowlist.
|
||||
mp, hasWildcards = makeDomainMap(opt.DomainAllowlist)
|
||||
im.domainAllowlist = mp
|
||||
im.hasAllowlistWildcards = hasWildcards
|
||||
im.hasAllowlist = len(mp) > 0
|
||||
|
||||
return &im
|
||||
}
|
||||
|
||||
// NewSession returns an new instance of Session. It takes the name
|
||||
// of the uploaded file, but doesn't do anything with it but retains it for stats.
|
||||
func (im *Importer) NewSession(opt SessionOpt) (*Session, error) {
|
||||
if !im.isDone() {
|
||||
return nil, errors.New("an import is already running")
|
||||
}
|
||||
|
||||
// For API backwards compatibility, if the old 'overwrite'
|
||||
// field is set, set both overwrite fields to true.
|
||||
if opt.Overwrite {
|
||||
opt.OverwriteUserInfo = true
|
||||
opt.OverwriteSubStatus = true
|
||||
}
|
||||
|
||||
im.Lock()
|
||||
im.status = Status{Status: StatusImporting,
|
||||
Name: opt.Filename,
|
||||
logBuf: bytes.NewBuffer(nil)}
|
||||
im.Unlock()
|
||||
|
||||
s := &Session{
|
||||
im: im,
|
||||
log: log.New(im.status.logBuf, "", log.Ldate|log.Ltime|log.Lmicroseconds|log.Lshortfile),
|
||||
subQueue: make(chan SubReq, commitBatchSize),
|
||||
opt: opt,
|
||||
}
|
||||
|
||||
s.log.Printf("processing '%s'", opt.Filename)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// GetStats returns the global Stats of the importer.
|
||||
func (im *Importer) GetStats() Status {
|
||||
im.RLock()
|
||||
defer im.RUnlock()
|
||||
|
||||
return Status{
|
||||
Name: im.status.Name,
|
||||
Status: im.status.Status,
|
||||
Total: im.status.Total,
|
||||
Imported: im.status.Imported,
|
||||
}
|
||||
}
|
||||
|
||||
// GetLogs returns the log entries of the last import session.
|
||||
func (im *Importer) GetLogs() []byte {
|
||||
im.RLock()
|
||||
defer im.RUnlock()
|
||||
|
||||
if im.status.logBuf == nil {
|
||||
return []byte{}
|
||||
}
|
||||
|
||||
return im.status.logBuf.Bytes()
|
||||
}
|
||||
|
||||
// setStatus sets the Importer's status.
|
||||
func (im *Importer) setStatus(status string) {
|
||||
im.Lock()
|
||||
im.status.Status = status
|
||||
im.Unlock()
|
||||
}
|
||||
|
||||
// getStatus get's the Importer's status.
|
||||
func (im *Importer) getStatus() string {
|
||||
im.RLock()
|
||||
status := im.status.Status
|
||||
im.RUnlock()
|
||||
return status
|
||||
}
|
||||
|
||||
// isDone returns true if the importer is working (importing|stopping).
|
||||
func (im *Importer) isDone() bool {
|
||||
s := true
|
||||
im.RLock()
|
||||
if im.getStatus() == StatusImporting || im.getStatus() == StatusStopping {
|
||||
s = false
|
||||
}
|
||||
im.RUnlock()
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// incrementImportCount sets the Importer's "imported" counter.
|
||||
func (im *Importer) incrementImportCount(n int) {
|
||||
im.Lock()
|
||||
im.status.Imported += n
|
||||
im.Unlock()
|
||||
}
|
||||
|
||||
// sendNotif sends admin notifications for import completions.
|
||||
func (im *Importer) sendNotif(status string) error {
|
||||
var (
|
||||
s = im.GetStats()
|
||||
out = importStatusTpl{
|
||||
Name: s.Name,
|
||||
Status: status,
|
||||
Imported: s.Imported,
|
||||
Total: s.Total,
|
||||
}
|
||||
subject = fmt.Sprintf("%s: %s import", cases.Title(language.Und).String(status), s.Name)
|
||||
)
|
||||
return im.opt.PostCB(subject, out)
|
||||
}
|
||||
|
||||
// Start is a blocking function that selects on a channel queue until all
|
||||
// subscriber entries in the import session are imported. It should be
|
||||
// invoked as a goroutine.
|
||||
func (s *Session) Start() {
|
||||
var (
|
||||
tx *sql.Tx
|
||||
stmt *sql.Stmt
|
||||
err error
|
||||
total = 0
|
||||
cur = 0
|
||||
)
|
||||
|
||||
listIDs := make([]int, len(s.opt.ListIDs))
|
||||
copy(listIDs, s.opt.ListIDs)
|
||||
|
||||
for sub := range s.subQueue {
|
||||
if cur == 0 {
|
||||
// New transaction batch.
|
||||
tx, err = s.im.db.Begin()
|
||||
if err != nil {
|
||||
s.log.Printf("error creating DB transaction: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if s.opt.Mode == ModeSubscribe {
|
||||
stmt = tx.Stmt(s.im.opt.UpsertStmt)
|
||||
} else {
|
||||
stmt = tx.Stmt(s.im.opt.BlocklistStmt)
|
||||
}
|
||||
}
|
||||
|
||||
uu, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
s.log.Printf("error generating UUID: %v", err)
|
||||
tx.Rollback()
|
||||
break
|
||||
}
|
||||
|
||||
if s.opt.Mode == ModeSubscribe {
|
||||
_, err = stmt.Exec(uu, sub.Email, sub.Name, sub.Attribs, pq.Array(listIDs), s.opt.SubStatus, s.opt.OverwriteUserInfo, s.opt.OverwriteSubStatus)
|
||||
} else if s.opt.Mode == ModeBlocklist {
|
||||
_, err = stmt.Exec(uu, sub.Email, sub.Name, sub.Attribs)
|
||||
}
|
||||
if err != nil {
|
||||
s.log.Printf("error executing insert: %v", err)
|
||||
tx.Rollback()
|
||||
break
|
||||
}
|
||||
cur++
|
||||
total++
|
||||
|
||||
// Batch size is met. Commit.
|
||||
if cur%commitBatchSize == 0 {
|
||||
if err := tx.Commit(); err != nil {
|
||||
tx.Rollback()
|
||||
s.log.Printf("error committing to DB: %v", err)
|
||||
} else {
|
||||
s.im.incrementImportCount(cur)
|
||||
s.log.Printf("imported %d", total)
|
||||
}
|
||||
|
||||
cur = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Queue's closed and there's nothing left to commit.
|
||||
if cur == 0 {
|
||||
s.im.setStatus(StatusFinished)
|
||||
s.log.Printf("imported finished")
|
||||
if _, err := s.im.opt.UpdateListDateStmt.Exec(pq.Array(listIDs)); err != nil {
|
||||
s.log.Printf("error updating lists date: %v", err)
|
||||
}
|
||||
s.im.sendNotif(StatusFinished)
|
||||
return
|
||||
}
|
||||
|
||||
// Queue's closed and there are records left to commit.
|
||||
if err := tx.Commit(); err != nil {
|
||||
tx.Rollback()
|
||||
s.im.setStatus(StatusFailed)
|
||||
s.log.Printf("error committing to DB: %v", err)
|
||||
s.im.sendNotif(StatusFailed)
|
||||
return
|
||||
}
|
||||
|
||||
s.im.incrementImportCount(cur)
|
||||
s.im.setStatus(StatusFinished)
|
||||
s.log.Printf("imported finished")
|
||||
if _, err := s.im.opt.UpdateListDateStmt.Exec(pq.Array(listIDs)); err != nil {
|
||||
s.log.Printf("error updating lists date: %v", err)
|
||||
}
|
||||
|
||||
s.im.sendNotif(StatusFinished)
|
||||
}
|
||||
|
||||
// Stop stops an active import session.
|
||||
func (s *Session) Stop() {
|
||||
close(s.subQueue)
|
||||
}
|
||||
|
||||
// ExtractZIP takes a ZIP file's path and extracts all .csv files in it to
|
||||
// a temporary directory, and returns the name of the temp directory and the
|
||||
// list of extracted .csv files.
|
||||
func (s *Session) ExtractZIP(srcPath string, maxCSVs int) (string, []string, error) {
|
||||
if s.im.isDone() {
|
||||
return "", nil, ErrIsImporting
|
||||
}
|
||||
|
||||
failed := true
|
||||
defer func() {
|
||||
if failed {
|
||||
s.im.setStatus(StatusFailed)
|
||||
}
|
||||
}()
|
||||
|
||||
z, err := zip.OpenReader(srcPath)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
defer z.Close()
|
||||
|
||||
// Create a temporary directory to extract the files.
|
||||
dir, err := os.MkdirTemp("", "eaglecast")
|
||||
if err != nil {
|
||||
s.log.Printf("error creating temporary directory for extracting ZIP: %v", err)
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
files := make([]string, 0, len(z.File))
|
||||
for _, f := range z.File {
|
||||
fName := f.FileInfo().Name()
|
||||
|
||||
// Skip directories.
|
||||
if f.FileInfo().IsDir() {
|
||||
s.log.Printf("skipping directory '%s'", fName)
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip files without the .csv extension.
|
||||
if !strings.HasSuffix(strings.ToLower(fName), ".csv") {
|
||||
s.log.Printf("skipping non .csv file '%s'", fName)
|
||||
continue
|
||||
}
|
||||
|
||||
// Sanitize the file name to prevent ZIP slip path traversal.
|
||||
fName = filepath.Base(fName)
|
||||
|
||||
s.log.Printf("extracting '%s'", fName)
|
||||
src, err := f.Open()
|
||||
if err != nil {
|
||||
s.log.Printf("error opening '%s' from ZIP: '%v'", fName, err)
|
||||
return "", nil, err
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
out, err := os.OpenFile(dir+"/"+fName, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
|
||||
if err != nil {
|
||||
s.log.Printf("error creating '%s/%s': '%v'", dir, fName, err)
|
||||
return "", nil, err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
if _, err := io.Copy(out, src); err != nil {
|
||||
s.log.Printf("error extracting to '%s/%s': '%v'", dir, fName, err)
|
||||
return "", nil, err
|
||||
}
|
||||
s.log.Printf("extracted '%s'", fName)
|
||||
|
||||
files = append(files, fName)
|
||||
if len(files) > maxCSVs {
|
||||
s.log.Printf("won't extract any more files. Maximum is %d", maxCSVs)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(files) == 0 {
|
||||
s.log.Println("no CSV files found in the ZIP")
|
||||
return "", nil, errors.New("no CSV files found in the ZIP")
|
||||
}
|
||||
|
||||
failed = false
|
||||
return dir, files, nil
|
||||
}
|
||||
|
||||
// LoadCSV loads a CSV file and validates and imports the subscriber entries in it.
|
||||
func (s *Session) LoadCSV(srcPath string, delim rune) error {
|
||||
if s.im.isDone() {
|
||||
return ErrIsImporting
|
||||
}
|
||||
|
||||
// Default status is "failed" in case the function
|
||||
// returns at one of the many possible errors.
|
||||
failed := true
|
||||
defer func() {
|
||||
if failed {
|
||||
s.im.setStatus(StatusFailed)
|
||||
}
|
||||
}()
|
||||
|
||||
f, err := os.Open(srcPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Count the total number of lines in the file. This doesn't distinguish
|
||||
// between "blank" and non "blank" lines, and is only used to derive
|
||||
// the progress percentage for the frontend.
|
||||
numLines, err := countLines(f)
|
||||
if err != nil {
|
||||
s.log.Printf("error counting lines in '%s': '%v'", srcPath, err)
|
||||
return err
|
||||
}
|
||||
|
||||
if numLines == 0 {
|
||||
return errors.New("empty file")
|
||||
}
|
||||
|
||||
// Exclude the header from count.
|
||||
s.im.Lock()
|
||||
s.im.status.Total = numLines - 1
|
||||
s.im.Unlock()
|
||||
|
||||
// Rewind, now that we've done a linecount on the same handler.
|
||||
_, _ = f.Seek(0, 0)
|
||||
rd := csv.NewReader(f)
|
||||
rd.Comma = delim
|
||||
|
||||
// Read the header.
|
||||
csvHdr, err := rd.Read()
|
||||
if err != nil {
|
||||
s.log.Printf("error reading header from '%s': '%v'", srcPath, err)
|
||||
return err
|
||||
}
|
||||
|
||||
hdrKeys := s.mapCSVHeaders(csvHdr, csvHeaders)
|
||||
// email is a required header.
|
||||
if _, ok := hdrKeys["email"]; !ok {
|
||||
s.log.Printf("'email' column not found in '%s'", srcPath)
|
||||
return errors.New("'email' column not found")
|
||||
}
|
||||
|
||||
var (
|
||||
lnHdr = len(hdrKeys)
|
||||
i = 0
|
||||
)
|
||||
for {
|
||||
i++
|
||||
|
||||
// Check for the stop signal.
|
||||
select {
|
||||
case <-s.im.stop:
|
||||
failed = false
|
||||
close(s.subQueue)
|
||||
s.log.Println("stop request received")
|
||||
return nil
|
||||
default:
|
||||
}
|
||||
|
||||
cols, err := rd.Read()
|
||||
if err == io.EOF {
|
||||
break
|
||||
} else if err != nil {
|
||||
if err, ok := err.(*csv.ParseError); ok && err.Err == csv.ErrFieldCount {
|
||||
s.log.Printf("skipping line %d. %v", i, err)
|
||||
continue
|
||||
} else {
|
||||
s.log.Printf("error reading CSV '%s'", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
lnCols := len(cols)
|
||||
if lnCols < lnHdr {
|
||||
s.log.Printf("skipping line %d. column count (%d) does not match minimum header count (%d)", i, lnCols, lnHdr)
|
||||
continue
|
||||
}
|
||||
|
||||
// Iterate the key map and based on the indices mapped earlier,
|
||||
// form a map of key: csv_value, eg: email: user@user.com.
|
||||
row := make(map[string]string, lnCols)
|
||||
for key := range hdrKeys {
|
||||
row[key] = cols[hdrKeys[key]]
|
||||
}
|
||||
|
||||
sub := SubReq{}
|
||||
sub.Email = row["email"]
|
||||
|
||||
if v, ok := row["name"]; ok {
|
||||
sub.Name = v
|
||||
}
|
||||
|
||||
sub, err = s.im.ValidateFields(sub)
|
||||
if err != nil {
|
||||
s.log.Printf("skipping line %d: %v: %v", i, err, cols)
|
||||
continue
|
||||
}
|
||||
|
||||
// JSON attributes.
|
||||
if len(row["attributes"]) > 0 {
|
||||
var (
|
||||
attribs models.JSON
|
||||
b = []byte(row["attributes"])
|
||||
)
|
||||
if err := json.Unmarshal(b, &attribs); err != nil {
|
||||
s.log.Printf("skipping invalid attributes JSON on line %d for '%s': %v", i, sub.Email, err)
|
||||
} else {
|
||||
sub.Attribs = attribs
|
||||
}
|
||||
}
|
||||
|
||||
// Send the subscriber to the queue.
|
||||
s.subQueue <- sub
|
||||
}
|
||||
|
||||
close(s.subQueue)
|
||||
failed = false
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop sends a signal to stop the existing import.
|
||||
func (im *Importer) Stop() {
|
||||
if im.getStatus() != StatusImporting {
|
||||
im.Lock()
|
||||
im.status = Status{Status: StatusNone}
|
||||
im.Unlock()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case im.stop <- true:
|
||||
im.setStatus(StatusStopping)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// SanitizeEmail validates and sanitizes an e-mail string and returns the
|
||||
// canonical (lowercased, trimmed) address. Domain allowlist/blocklist rules
|
||||
// are enforced on top of the bare-address validation in utils.SanitizeEmail.
|
||||
func (im *Importer) SanitizeEmail(email string) (string, error) {
|
||||
addr, err := utils.SanitizeEmail(email)
|
||||
if err != nil {
|
||||
return "", errors.New(im.i18n.T("subscribers.invalidEmail"))
|
||||
}
|
||||
|
||||
// Check if the e-mail's domain is blocklisted. The e-mail domain and blocklist config
|
||||
// are always lowercase.
|
||||
if im.hasAllowlist || im.hasBlocklist {
|
||||
d := strings.Split(addr, "@")
|
||||
if len(d) != 2 {
|
||||
return addr, nil
|
||||
}
|
||||
|
||||
domain := d[1]
|
||||
|
||||
// If there's an allowlist, check if the domain is in it. Checking blocklist after that is moot.
|
||||
if im.hasAllowlist {
|
||||
if !im.checkInList(domain, im.hasAllowlistWildcards, im.domainAllowlist) {
|
||||
return "", errors.New(im.i18n.T("subscribers.domainBlocklisted"))
|
||||
}
|
||||
} else if im.hasBlocklist {
|
||||
if im.checkInList(domain, im.hasBlocklistWildcards, im.domainBlocklist) {
|
||||
return "", errors.New(im.i18n.T("subscribers.domainBlocklisted"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return addr, nil
|
||||
}
|
||||
|
||||
// ValidateFields validates incoming subscriber field values and returns sanitized fields.
|
||||
func (im *Importer) ValidateFields(s SubReq) (SubReq, error) {
|
||||
if len(s.Email) > 1000 {
|
||||
return s, errors.New(im.i18n.T("subscribers.invalidEmail"))
|
||||
}
|
||||
|
||||
em, err := im.SanitizeEmail(s.Email)
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
s.Email = strings.ToLower(em)
|
||||
|
||||
// If there's no name, use the name part of the e-mail.
|
||||
s.Name = strings.TrimSpace(s.Name)
|
||||
if len(s.Name) == 0 {
|
||||
name := strings.ToLower(strings.Split(s.Email, "@")[0])
|
||||
|
||||
parts := strings.Fields(strings.ReplaceAll(name, ".", " "))
|
||||
for n, p := range parts {
|
||||
parts[n] = cases.Title(language.Und).String(p)
|
||||
}
|
||||
|
||||
s.Name = strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Check the domain against the given map of domains (block/allowlist).
|
||||
func (im *Importer) checkInList(domain string, hasWildcards bool, mp map[string]struct{}) bool {
|
||||
// Check the domain as-is.
|
||||
if _, ok := mp[domain]; ok {
|
||||
return true
|
||||
}
|
||||
|
||||
// If there are wildcards in the list and the email domain has a subdomain, check that.
|
||||
if hasWildcards && strings.Count(domain, ".") > 1 {
|
||||
parts := strings.Split(domain, ".")
|
||||
|
||||
// Replace the first part of the subdomain with * and check if that exists in the list.
|
||||
// Eg: test.mail.example.com => *.mail.example.com
|
||||
parts[0] = "*"
|
||||
domain = strings.Join(parts, ".")
|
||||
|
||||
if _, ok := mp[domain]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// mapCSVHeaders takes a list of headers obtained from a CSV file, a map of known headers,
|
||||
// and returns a new map with each of the headers in the known map mapped by the position (0-n)
|
||||
// in the given CSV list.
|
||||
func (s *Session) mapCSVHeaders(csvHdrs []string, knownHdrs map[string]bool) map[string]int {
|
||||
// Map 0-n column index to the header keys, name: 0, email: 1 etc.
|
||||
// This is to allow dynamic ordering of columns in th CSV.
|
||||
hdrKeys := make(map[string]int)
|
||||
for i, h := range csvHdrs {
|
||||
// Clean the string of non-ASCII characters (BOM etc.).
|
||||
h := regexCleanStr.ReplaceAllString(strings.TrimSpace(h), "")
|
||||
if _, ok := knownHdrs[h]; !ok {
|
||||
s.log.Printf("ignoring unknown header '%s'", h)
|
||||
continue
|
||||
}
|
||||
hdrKeys[h] = i
|
||||
}
|
||||
|
||||
return hdrKeys
|
||||
}
|
||||
|
||||
// countLines counts the number of line breaks in a file. This does not
|
||||
// distinguish between "blank" and non "blank" lines.
|
||||
// Credit: https://stackoverflow.com/a/24563853
|
||||
func countLines(r io.Reader) (int, error) {
|
||||
var (
|
||||
buf = make([]byte, 32*1024)
|
||||
count = 0
|
||||
lineSep = byte('\n')
|
||||
lastByte byte
|
||||
)
|
||||
|
||||
for {
|
||||
c, err := r.Read(buf)
|
||||
if c > 0 {
|
||||
count += bytes.Count(buf[:c], []byte{lineSep})
|
||||
lastByte = buf[c-1]
|
||||
}
|
||||
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return count, err
|
||||
}
|
||||
}
|
||||
|
||||
if lastByte != 0 && lastByte != lineSep {
|
||||
count++
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func makeDomainMap(domains []string) (map[string]struct{}, bool) {
|
||||
var (
|
||||
out = make(map[string]struct{}, len(domains))
|
||||
hasWildCards = false
|
||||
)
|
||||
for _, d := range domains {
|
||||
out[d] = struct{}{}
|
||||
|
||||
// Domains with *. as the subdomain prefix, strip that
|
||||
// and add the full domain to the blocklist as well.
|
||||
// eg: *.example.com => example.com
|
||||
if strings.Contains(d, "*.") {
|
||||
hasWildCards = true
|
||||
out[strings.TrimPrefix(d, "*.")] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
return out, hasWildCards
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// Package tmptokens provides a simple in memory store for one time temp tokens with TTL.
|
||||
// This can be used for creating throwaway tokens for flows like password reset, 2FA verification, etc.
|
||||
// Tokens are automatically deleted when retrieved or when they expire.
|
||||
package tmptokens
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// maxTries is the maximum number of verification attempts allowed for a token.
|
||||
// After this many failed checks, the token is automatically deleted.
|
||||
maxTries = 15
|
||||
)
|
||||
|
||||
// Token represents a temporary token with TTL and arbitrary data.
|
||||
type Token struct {
|
||||
TTL time.Duration
|
||||
CreatedAt time.Time
|
||||
Count int
|
||||
Data any
|
||||
}
|
||||
|
||||
var (
|
||||
Err = errors.New("token was not found or has expired")
|
||||
|
||||
tokens = make(map[string]Token)
|
||||
mu sync.RWMutex
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Start periodic cleanup of expired temporary tokens (2FA, password reset).
|
||||
go func() {
|
||||
ticker := time.NewTicker(time.Hour)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
Clean()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Set stores a token with the given ID, TTL, and data.
|
||||
// If a token with the same ID already exists, it will be overwritten silently.
|
||||
func Set(id string, ttl time.Duration, data any) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
tokens[id] = Token{
|
||||
TTL: ttl,
|
||||
Data: data,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// Check retrieves a token by ID without deleting it.
|
||||
// An error is returned if the token doesn't exist or has expired.
|
||||
// Unlike Get(), this method does not consume/delete the token.
|
||||
// It also increments the check counter and deletes the token if maxTries is exceeded,
|
||||
// acting as a rate limiter.
|
||||
func Check(id string) (any, error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
token, exists := tokens[id]
|
||||
if !exists {
|
||||
return nil, Err
|
||||
}
|
||||
|
||||
// Check if token has expired.
|
||||
if time.Since(token.CreatedAt) > token.TTL {
|
||||
delete(tokens, id)
|
||||
return nil, Err
|
||||
}
|
||||
|
||||
// Increment the rate limit counter.
|
||||
token.Count++
|
||||
|
||||
// Check if max attempts exceeded.
|
||||
if token.Count > maxTries {
|
||||
delete(tokens, id)
|
||||
return nil, Err
|
||||
}
|
||||
|
||||
// Update the token with the new count.
|
||||
tokens[id] = token
|
||||
|
||||
return token.Data, nil
|
||||
}
|
||||
|
||||
// Get retrieves a token by ID and automatically deletes it (after one time use).
|
||||
// An error is returned if the token doesn't exist or has expired.
|
||||
func Get(id string) (any, error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
token, exists := tokens[id]
|
||||
if !exists {
|
||||
return nil, Err
|
||||
}
|
||||
|
||||
// Check if token has expired.
|
||||
if time.Since(token.CreatedAt) > token.TTL {
|
||||
delete(tokens, id)
|
||||
return nil, Err
|
||||
}
|
||||
|
||||
// Delete the token.
|
||||
delete(tokens, id)
|
||||
|
||||
return token.Data, nil
|
||||
}
|
||||
|
||||
// Delete deletes a token by ID.
|
||||
func Delete(id string) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
delete(tokens, id)
|
||||
}
|
||||
|
||||
// Clean deletes all expired tokens. This can be called periodically
|
||||
// to purge unused and expired tokens.
|
||||
func Clean() {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
for id, token := range tokens {
|
||||
if now.Sub(token.CreatedAt) > token.TTL {
|
||||
delete(tokens, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrInvalidEmail is returned by SanitizeEmail for malformed input.
|
||||
var ErrInvalidEmail = errors.New("invalid e-mail address")
|
||||
|
||||
// ValidateEmail reports whether s is a correctly formed bare e-mail address
|
||||
// (no display name component).
|
||||
func ValidateEmail(s string) bool {
|
||||
_, err := SanitizeEmail(s)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// SanitizeEmail trims, lowercases, and validates s as a bare e-mail address
|
||||
// (no display name) and returns the canonical form. Returns ErrInvalidEmail
|
||||
// for anything `mail.ParseAddress` rejects or for input with a display name.
|
||||
func SanitizeEmail(s string) (string, error) {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
em, err := mail.ParseAddress(s)
|
||||
if err != nil || em.Address != s {
|
||||
return "", ErrInvalidEmail
|
||||
}
|
||||
return em.Address, nil
|
||||
}
|
||||
|
||||
// ParseEmailAddress extracts the lowercased bare address from an RFC 5322
|
||||
// "From"-style header value, accepting both bare addresses ("a@b.com") and
|
||||
// the display-name form ("Name <a@b.com>"). Returns "" if unparseable.
|
||||
func ParseEmailAddress(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
em, err := mail.ParseAddress(s)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(em.Address)
|
||||
}
|
||||
|
||||
// GenerateRandomString generates a cryptographically random, alphanumeric string of length n.
|
||||
func GenerateRandomString(n int) (string, error) {
|
||||
const dictionary = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
var bytes = make([]byte, n)
|
||||
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for k, v := range bytes {
|
||||
bytes[k] = dictionary[v%byte(len(dictionary))]
|
||||
}
|
||||
|
||||
return string(bytes), nil
|
||||
}
|
||||
|
||||
// SanitizeURI takes a URL or URI, removes the domain from it, returns only the URI.
|
||||
// This is used for cleaning "next" redirect URLs/URIs to prevent open redirects.
|
||||
func SanitizeURI(u string) string {
|
||||
u = strings.TrimSpace(u)
|
||||
if u == "" {
|
||||
return "/"
|
||||
}
|
||||
|
||||
p, err := url.Parse(u)
|
||||
if err != nil || strings.Contains(p.Path, "..") {
|
||||
return "/"
|
||||
}
|
||||
|
||||
return path.Clean(p.Path)
|
||||
}
|
||||
Reference in New Issue
Block a user