From e98a25d1f6d44e0ad2b44ab08dd7a3e47d9e33de Mon Sep 17 00:00:00 2001
From: ale <ale@incal.net>
Date: Wed, 1 Apr 2020 08:43:04 +0100
Subject: [PATCH] Shelve off temporary changes to a separate branch

---
 backend/ldap/model.go |  17 +
 service.go            |   1 +
 template.go           | 225 +++++++++++
 validation_helpers.go | 126 ++++++
 validators.go         | 915 ++++++++++++++++++++----------------------
 5 files changed, 797 insertions(+), 487 deletions(-)
 create mode 100644 template.go
 create mode 100644 validation_helpers.go

diff --git a/backend/ldap/model.go b/backend/ldap/model.go
index ec54abb3..a0f4ae1a 100644
--- a/backend/ldap/model.go
+++ b/backend/ldap/model.go
@@ -3,6 +3,7 @@ package ldapbackend
 import (
 	"bytes"
 	"context"
+	"errors"
 	"fmt"
 	"math/rand"
 	"strconv"
@@ -525,6 +526,22 @@ func (tx *backendTX) searchResourcesByType(ctx context.Context, pattern, resourc
 	return out, nil
 }
 
+// FindResource fetches a specific resource by type and name.
+func (tx *backendTX) FindResource(ctx context.Context, spec as.FindResourceRequest) (*as.RawResource, error) {
+	result, err := tx.searchResourcesByType(ctx, spec.Name, spec.Type)
+	if err != nil {
+		return nil, err
+	}
+	switch len(result) {
+	case 0:
+		return nil, nil
+	case 1:
+		return result[0], nil
+	default:
+		return nil, errors.New("too many results")
+	}
+}
+
 // SearchResource returns all the resources matching the pattern.
 func (tx *backendTX) SearchResource(ctx context.Context, pattern string) ([]*as.RawResource, error) {
 	// Aggregate results for all known resource types.
diff --git a/service.go b/service.go
index d2d02d2f..450c51e4 100644
--- a/service.go
+++ b/service.go
@@ -51,6 +51,7 @@ type TX interface {
 	UpdateResource(context.Context, *Resource) error
 	CreateResources(context.Context, *User, []*Resource) ([]*Resource, error)
 	SetResourcePassword(context.Context, *Resource, string) error
+	FindResource(context.Context, FindResourceRequest) (*RawResource, error)
 	HasAnyResource(context.Context, []FindResourceRequest) (bool, error)
 
 	GetUser(context.Context, string) (*RawUser, error)
diff --git a/template.go b/template.go
new file mode 100644
index 00000000..a9d5b33d
--- /dev/null
+++ b/template.go
@@ -0,0 +1,225 @@
+package accountserver
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"math/rand"
+	"path/filepath"
+	"strings"
+	"time"
+)
+
+// Templating, in this context, means filling up derived attributes in a resource.
+//
+// Derived attributes are always enforced (i.e. we don't check if the
+// attribute already has a value), but currently this is only done at
+// resource creation time. The plan is to extend this to cover any
+// Update call as well.
+type templateContext struct {
+	shards  shardBackend
+	webroot string
+}
+
+func (c *templateContext) pickShard(ctx context.Context, r *Resource) (string, error) {
+	avail := c.shards.GetAvailableShards(ctx, r.Type)
+	if len(avail) == 0 {
+		return "", fmt.Errorf("no available shards for resource type %s", r.Type)
+	}
+	return avail[rand.Intn(len(avail))], nil
+}
+
+func (c *templateContext) setResourceShard(ctx context.Context, r *Resource, ref *Resource) error {
+	if r.Shard == "" {
+		if ref != nil {
+			r.Shard = ref.Shard
+		} else {
+			s, err := c.pickShard(ctx, r)
+			if err != nil {
+				return err
+			}
+			r.Shard = s
+		}
+	}
+	if r.OriginalShard == "" {
+		r.OriginalShard = r.Shard
+	}
+	return nil
+}
+
+func (c *templateContext) setResourceStatus(r *Resource) {
+	if r.Status == "" {
+		r.Status = ResourceStatusActive
+	}
+}
+
+func (c *templateContext) setCommonResourceAttrs(ctx context.Context, r *Resource, ref *Resource, user *User) error {
+	// If we reference another resource, ensure it has been templated.
+	if ref != nil {
+		if err := c.applyTemplate(ctx, ref, user); err != nil {
+			return err
+		}
+	}
+
+	r.CreatedAt = time.Now().UTC().Format("2006-01-02")
+
+	c.setResourceStatus(r)
+	return c.setResourceShard(ctx, r, ref)
+}
+
+// Apply default values to an Email resource.
+func (c *templateContext) emailResourceTemplate(ctx context.Context, r *Resource, _ *User) error {
+	// Force the email address to lowercase.
+	r.Name = strings.ToLower(r.Name)
+
+	if r.Email == nil {
+		r.Email = new(Email)
+	}
+
+	addrParts := strings.Split(r.Name, "@")
+	if len(addrParts) != 2 {
+		return errors.New("malformed name")
+	}
+	r.Email.Maildir = fmt.Sprintf("%s/%s", addrParts[1], addrParts[0])
+	r.Email.QuotaLimit = 4096
+	return c.setCommonResourceAttrs(ctx, r, nil, nil)
+}
+
+// Apply default values to a Website or Domain resource.
+func (c *templateContext) websiteResourceTemplate(ctx context.Context, r *Resource, user *User) error {
+	if user == nil {
+		return errors.New("website resource needs owner")
+	}
+
+	// Force the website address to lowercase.
+	r.Name = strings.ToLower(r.Name)
+
+	if r.Website == nil {
+		r.Website = new(Website)
+	}
+
+	// If the client did not specify a DocumentRoot, find a DAV resource
+	// and associate the website with it.
+	if r.Website.DocumentRoot == "" {
+		dav := user.GetSingleResourceByType(ResourceTypeDAV)
+		if dav == nil {
+			return errors.New("user has no DAV accounts")
+		}
+
+		// The DAV resource may not have been templatized yet.
+		if dav.DAV == nil || dav.DAV.Homedir == "" {
+			if err := c.davResourceTemplate(ctx, dav, user); err != nil {
+				return err
+			}
+		}
+		r.Website.DocumentRoot = filepath.Join(dav.DAV.Homedir, "html-"+r.Name)
+	}
+	r.Website.DocumentRoot = filepath.Clean(r.Website.DocumentRoot)
+
+	if len(r.Website.Options) == 0 {
+		r.Website.Options = []string{"nomail"}
+	}
+
+	r.Website.UID = user.UID
+
+	dav := findMatchingDAVAccount(user, r)
+	if dav == nil {
+		return fmt.Errorf("no DAV resources matching website %s", r.String())
+	}
+	return c.setCommonResourceAttrs(ctx, r, dav, user)
+}
+
+// Apply default values to a DAV resource.
+func (c *templateContext) davResourceTemplate(ctx context.Context, r *Resource, user *User) error {
+	if user == nil {
+		return errors.New("dav resource needs owner")
+	}
+
+	// Force the account name to lowercase.
+	r.Name = strings.ToLower(r.Name)
+
+	if r.DAV == nil {
+		r.DAV = new(WebDAV)
+	}
+	if r.DAV.Homedir == "" {
+		r.DAV.Homedir = filepath.Join(c.webroot, r.Name)
+	}
+	r.DAV.Homedir = filepath.Clean(r.DAV.Homedir)
+	r.DAV.UID = user.UID
+
+	return c.setCommonResourceAttrs(ctx, r, nil, user)
+}
+
+// Apply default values to a Database resource.
+func (c *templateContext) databaseResourceTemplate(ctx context.Context, r *Resource, user *User) error {
+	if user == nil {
+		return errors.New("database resource needs owner")
+	}
+
+	// Force the database name to lowercase.
+	r.Name = strings.ToLower(r.Name)
+
+	if r.Database == nil {
+		r.Database = new(Database)
+	}
+	if r.Database.DBUser == "" {
+		r.Database.DBUser = r.Name
+	}
+
+	return c.setCommonResourceAttrs(ctx, r, user.GetResourceByID(r.ParentID), user)
+}
+
+// Apply default values to a MailingList resource.
+func (c *templateContext) listResourceTemplate(ctx context.Context, r *Resource, user *User) error {
+	// Force the list address to lowercase.
+	r.Name = strings.ToLower(r.Name)
+
+	if r.List == nil {
+		r.List = new(MailingList)
+	}
+
+	// As a convenience, if a user is passed in the context, we add it to
+	// the list admins.
+	if user != nil && len(r.List.Admins) == 0 {
+		r.List.Admins = []string{user.Name}
+	}
+
+	return c.setCommonResourceAttrs(ctx, r, nil, nil)
+}
+
+// Apply default values to a Newsletter resource.
+func (c *templateContext) newsletterResourceTemplate(ctx context.Context, r *Resource, user *User) error {
+	// Force the list address to lowercase.
+	r.Name = strings.ToLower(r.Name)
+
+	if r.Newsletter == nil {
+		r.Newsletter = new(Newsletter)
+	}
+
+	// As a convenience, if a user is passed in the context, we add it to
+	// the list admins.
+	if user != nil && len(r.Newsletter.Admins) == 0 {
+		r.Newsletter.Admins = []string{user.Name}
+	}
+
+	return c.setCommonResourceAttrs(ctx, r, nil, nil)
+}
+
+// Apply default values to a resource.
+func (c *templateContext) applyTemplate(ctx context.Context, r *Resource, user *User) error {
+	switch r.Type {
+	case ResourceTypeEmail:
+		return c.emailResourceTemplate(ctx, r, user)
+	case ResourceTypeWebsite, ResourceTypeDomain:
+		return c.websiteResourceTemplate(ctx, r, user)
+	case ResourceTypeDAV:
+		return c.davResourceTemplate(ctx, r, user)
+	case ResourceTypeDatabase:
+		return c.databaseResourceTemplate(ctx, r, user)
+	case ResourceTypeMailingList:
+		return c.listResourceTemplate(ctx, r, user)
+	case ResourceTypeNewsletter:
+		return c.newsletterResourceTemplate(ctx, r, user)
+	}
+	return nil
+}
diff --git a/validation_helpers.go b/validation_helpers.go
new file mode 100644
index 00000000..b278f6ea
--- /dev/null
+++ b/validation_helpers.go
@@ -0,0 +1,126 @@
+package accountserver
+
+import (
+	"bufio"
+	"context"
+	"os"
+	"strings"
+)
+
+// Read non-empty lines from a file, ignoring comments and
+// leading/trailing whitespace.
+func loadStringListFromFile(path string) ([]string, error) {
+	f, err := os.Open(path) // #nosec
+	if err != nil {
+		return nil, err
+	}
+	defer f.Close() // nolint: errcheck
+
+	var list []string
+	scanner := bufio.NewScanner(f)
+	for scanner.Scan() {
+		line := scanner.Text()
+		if n := strings.Index(line, "#"); n >= 0 {
+			line = line[:n]
+		}
+		line = strings.TrimSpace(line)
+		if line != "" {
+			list = append(list, line)
+		}
+	}
+	return list, scanner.Err()
+}
+
+// A stringSet is just a list of strings with a quick membership test.
+type stringSet struct {
+	set  map[string]struct{}
+	list []string
+}
+
+func newStringSetFromList(list []string) *stringSet {
+	set := make(map[string]struct{})
+	for _, s := range list {
+		set[s] = struct{}{}
+	}
+	return &stringSet{set: set, list: list}
+}
+
+func newStringSetFromFileAndList(list []string, path string) (*stringSet, error) {
+	if path != "" {
+		more, err := loadStringListFromFile(path)
+		if err != nil {
+			return nil, err
+		}
+		list = append(list, more...)
+	}
+	return newStringSetFromList(list), nil
+}
+
+func (s stringSet) Contains(needle string) bool {
+	_, ok := s.set[needle]
+	return ok
+}
+
+func (s stringSet) Check(_ *RequestContext, value string) bool {
+	_, ok := s.set[value]
+	return ok
+}
+
+func (s stringSet) List(_ context.Context) []string {
+	return s.list
+}
+
+// List of email domains backed by our database (with ACLs).
+type emailDomainsDataset struct {
+	backend Backend
+}
+
+func (s *emailDomainsDataset) Check(rctx *RequestContext, value string) bool {
+	tx, err := s.backend.NewTransaction()
+	if err != nil {
+		// Fail close on database errors.
+		return false
+	}
+
+	// Ignore the error in FindResource() since we're going to fail close anyway.
+	// nolint: errcheck
+	rsrc, _ := tx.FindResource(rctx.Context, FindResourceRequest{Type: ResourceTypeDomain, Name: value})
+	if rsrc == nil {
+		return false
+	}
+
+	// Only allow domains that have acceptMail=true IF the
+	// requesting user is an admin.
+	//
+	// TODO: relax the ACL check allowing domain owners to create
+	// accounts?
+	if rsrc.Website.AcceptMail && rctx.Auth.IsAdmin {
+		return true
+	}
+
+	return false
+}
+
+func (s *emailDomainsDataset) List(_ context.Context) []string { return nil }
+
+// Combine multiple datasets.
+type multiDataset struct {
+	datasets []validationDataset
+}
+
+func (m *multiDataset) Check(rctx *RequestContext, value string) bool {
+	for _, d := range m.datasets {
+		if d.Check(rctx, value) {
+			return true
+		}
+	}
+	return false
+}
+
+func (m *multiDataset) List(ctx context.Context) []string {
+	var out []string
+	for _, d := range m.datasets {
+		out = append(out, d.List(ctx)...)
+	}
+	return out
+}
diff --git a/validators.go b/validators.go
index 4b6c8fb4..8995ef9e 100644
--- a/validators.go
+++ b/validators.go
@@ -1,31 +1,53 @@
 package accountserver
 
 import (
-	"bufio"
 	"context"
 	"errors"
 	"fmt"
-	"math/rand"
-	"os"
-	"path/filepath"
 	"regexp"
 	"strings"
-	"time"
 
 	"golang.org/x/net/publicsuffix"
 )
 
-// A domainBackend manages the list of domains users are allowed to request services on.
-type domainBackend interface {
-	GetAllowedDomains(context.Context, string) []string
-	IsAllowedDomain(context.Context, string, string) bool
+// Validation and templating require external data providers, lists of
+// domains etc, that might be provided by static files or dynamic
+// (database-backed) sources, represented by the following interfaces.
+type validationDataset interface {
+	// Check if a value is contained in the dataset. Since the
+	// dataset can be dynamic we pass the full RequestContext
+	// (which includes authentication information).
+	Check(*RequestContext, string) bool
+
+	// List all values in the dataset. Mostly used for debugging
+	// purposes.
+	List(context.Context) []string
+}
+
+// Some data sources are keyed by resource type, the following helper
+// type keeps the code more readable.
+type validationDatasetMap map[string]validationDataset
+
+func newValidationDatasetMap(m map[string][]string) validationDatasetMap {
+	v := make(validationDatasetMap)
+	for key, values := range m {
+		v[key] = newStringSetFromList(values)
+	}
+	return v
+}
+
+func (m validationDatasetMap) Check(rctx *RequestContext, key, value string) bool {
+	if v, ok := m[key]; ok {
+		return v.Check(rctx, value)
+	}
+	return false
 }
 
-// A shardBackend can return information about available / allowed service shards.
-type shardBackend interface {
-	GetAllowedShards(context.Context, string) []string
-	GetAvailableShards(context.Context, string) []string
-	IsAllowedShard(context.Context, string, string) bool
+func (m validationDatasetMap) List(ctx context.Context, key string) []string {
+	if v, ok := m[key]; ok {
+		return v.List(ctx)
+	}
+	return nil
 }
 
 // ValidationConfig specifies a large number of validation-related
@@ -45,10 +67,6 @@ type ValidationConfig struct {
 	MaxUsernameLen         int                 `yaml:"max_username_len"`
 	MinUID                 int                 `yaml:"min_backend_uid"`
 	MaxUID                 int                 `yaml:"max_backend_uid"`
-
-	forbiddenUsernames stringSet
-	forbiddenPasswords stringSet
-	forbiddenDomains   stringSet
 }
 
 const (
@@ -81,121 +99,38 @@ func (c *ValidationConfig) setDefaults() {
 	}
 }
 
-func (c *ValidationConfig) compile() (err error) {
-	c.setDefaults()
-
-	c.forbiddenUsernames, err = newStringSetFromFileOrList(c.ForbiddenUsernames, c.ForbiddenUsernamesFile)
-	if err != nil {
-		return
-	}
-	c.forbiddenPasswords, err = newStringSetFromFileOrList(c.ForbiddenPasswords, c.ForbiddenPasswordsFile)
-	if err != nil {
-		return
-	}
-	c.forbiddenDomains, err = newStringSetFromFileOrList(c.ForbiddenDomains, c.ForbiddenDomainsFile)
-	return
-}
-
 // The validationContext contains all configuration and backends that
 // the various validation functions will need. Most methods on this
 // object return functions themselves (ValidatorFunc or variations
 // thereof) that can later be called multiple times at will and
 // combined with operators like 'allOf'.
 type validationContext struct {
-	config  *ValidationConfig
-	domains domainBackend
-	shards  shardBackend
-	backend Backend
-}
-
-// A stringSet is just a list of strings with a quick membership test.
-type stringSet struct {
-	set  map[string]struct{}
-	list []string
-}
-
-func newStringSetFromList(list []string) stringSet {
-	set := make(map[string]struct{})
-	for _, s := range list {
-		set[s] = struct{}{}
-	}
-	return stringSet{set: set, list: list}
-}
-
-func newStringSetFromFileOrList(list []string, path string) (stringSet, error) {
-	if path != "" {
-		return loadStringSetFromFile(path)
-	}
-	return newStringSetFromList(list), nil
+	config             *ValidationConfig
+	availableDomains   validationDataset
+	allowedDomains     validationDataset
+	availableShards    validationDataset
+	allowedShards      validationDataset
+	forbiddenUsernames validationDataset
+	forbiddenPasswords validationDataset
+	forbiddenDomains   validationDataset
+	backend            Backend
 }
 
-func (s stringSet) Contains(needle string) bool {
-	_, ok := s.set[needle]
-	return ok
-}
-
-func (s stringSet) List() []string {
-	return s.list
-}
-
-// A domainBackend that works with a static list of type-specific allowed domains.
-type staticDomainBackend struct {
-	sets map[string]stringSet
-}
-
-func (d *staticDomainBackend) GetAllowedDomains(_ context.Context, kind string) []string {
-	return d.sets[kind].List()
-}
-
-func (d *staticDomainBackend) IsAllowedDomain(_ context.Context, kind, domain string) bool {
-	return d.sets[kind].Contains(domain)
-}
-
-type staticShardBackend struct {
-	available map[string]stringSet
-	allowed   map[string]stringSet
-}
-
-func (d *staticShardBackend) GetAllowedShards(_ context.Context, kind string) []string {
-	return d.allowed[kind].List()
-}
-
-func (d *staticShardBackend) GetAvailableShards(_ context.Context, kind string) []string {
-	return d.available[kind].List()
-}
-
-func (d *staticShardBackend) IsAllowedShard(_ context.Context, kind, shard string) bool {
-	return d.allowed[kind].Contains(shard)
-}
-
-func loadStringSetFromFile(path string) (stringSet, error) {
-	f, err := os.Open(path) // #nosec
-	if err != nil {
-		return stringSet{}, err
-	}
-	defer f.Close() // nolint: errcheck
+// ValidatorFunc is the generic interface for unstructured data field
+// (string) validators.
+type ValidatorFunc func(*RequestContext, string) error
 
-	var list []string
-	scanner := bufio.NewScanner(f)
-	for scanner.Scan() {
-		line := scanner.Text()
-		if line == "" {
-			continue
+func inSet(set stringSet) ValidatorFunc {
+	return func(_ *RequestContext, value string) error {
+		if !set.Contains(value) {
+			return errors.New("value not allowed")
 		}
-		list = append(list, line)
-	}
-	if err := scanner.Err(); err != nil {
-		return stringSet{}, err
+		return nil
 	}
-	return newStringSetFromList(list), nil
 }
 
-// ValidatorFunc is the generic interface for unstructured data field
-// (string) validators.
-type ValidatorFunc func(context.Context, string) error
-
 func notInSet(set stringSet) ValidatorFunc {
-	return func(_ context.Context, value string) error {
+	return func(_ *RequestContext, value string) error {
 		if set.Contains(value) {
 			return errors.New("invalid value (blacklisted)")
 		}
@@ -204,7 +139,7 @@ func notInSet(set stringSet) ValidatorFunc {
 }
 
 func minLength(minLen int) ValidatorFunc {
-	return func(_ context.Context, value string) error {
+	return func(_ *RequestContext, value string) error {
 		if len(value) < minLen {
 			return fmt.Errorf("value must be at least %d characters", minLen)
 		}
@@ -213,7 +148,7 @@ func minLength(minLen int) ValidatorFunc {
 }
 
 func maxLength(maxLen int) ValidatorFunc {
-	return func(_ context.Context, value string) error {
+	return func(_ *RequestContext, value string) error {
 		if len(value) > maxLen {
 			return fmt.Errorf("value must be at most %d characters", maxLen)
 		}
@@ -222,7 +157,7 @@ func maxLength(maxLen int) ValidatorFunc {
 }
 
 func matchRegexp(rx *regexp.Regexp, errmsg string) ValidatorFunc {
-	return func(_ context.Context, value string) error {
+	return func(_ *RequestContext, value string) error {
 		if !rx.MatchString(value) {
 			return errors.New(errmsg)
 		}
@@ -248,16 +183,29 @@ func matchIdentifierRx() ValidatorFunc {
 	return matchRegexp(identifierRx, "value must be alphanumeric")
 }
 
+// Validator that returns true only if all the arguments return true.
+func allOf(funcs ...ValidatorFunc) ValidatorFunc {
+	return func(rctx *RequestContext, value string) error {
+		for _, f := range funcs {
+			if err := f(rctx, value); err != nil {
+				return err
+			}
+		}
+		return nil
+	}
+}
+
+// Separately validate username and domain parts of an email address.
 func validateUsernameAndDomain(validateUsername, validateDomain ValidatorFunc) ValidatorFunc {
-	return func(ctx context.Context, value string) error {
+	return func(rctx *RequestContext, value string) error {
 		parts := strings.SplitN(value, "@", 2)
 		if len(parts) != 2 {
 			return errors.New("malformed email address")
 		}
-		if err := validateUsername(ctx, parts[0]); err != nil {
+		if err := validateUsername(rctx, parts[0]); err != nil {
 			return err
 		}
-		return validateDomain(ctx, parts[1])
+		return validateDomain(rctx, parts[1])
 	}
 }
 
@@ -270,7 +218,7 @@ func isRegistered(domain string) bool {
 	return true
 }
 
-func validDomainName(_ context.Context, value string) error {
+func validDomainName(_ *RequestContext, value string) error {
 	if !domainRx.MatchString(value) {
 		return errors.New("invalid domain name")
 	}
@@ -288,30 +236,41 @@ func validDomainName(_ context.Context, value string) error {
 	return nil
 }
 
-// Split a (correctly formed) email address into username/domain.
-func splitEmailAddr(addr string) (string, string) {
+// Split an email address into username and domain. The address should
+// be valid or we will panic.
+func splitAddr(addr string) (string, string) {
 	parts := strings.SplitN(addr, "@", 2)
 	return parts[0], parts[1]
 }
 
 // Returns all the possible resources in the email and mailing list
 // namespaces that might conflict with the given email address.
-func relatedEmails(ctx context.Context, be domainBackend, addr string) []FindResourceRequest {
+func relatedEmails(ctx context.Context, addr string) []FindResourceRequest {
+	return []FindResourceRequest{
+		{Type: ResourceTypeEmail, Name: addr},
+		{Type: ResourceTypeMailingList, Name: addr},
+		{Type: ResourceTypeNewsletter, Name: addr},
+	}
+}
+
+func relatedMailingLists(ctx context.Context, domains validationDatasetMap, addr string) []FindResourceRequest {
+	// Check for an email with the same address.
 	rel := []FindResourceRequest{
 		{Type: ResourceTypeEmail, Name: addr},
 	}
-	user, _ := splitEmailAddr(addr)
+
 	// Mailing lists and newsletters must have unique names
 	// regardless of the domain, so we add potential conflicts for
 	// mailing lists with the same name over all list-enabled
 	// domains.
-	for _, d := range be.GetAllowedDomains(ctx, ResourceTypeMailingList) {
+	user, _ := splitAddr(addr)
+	for _, d := range domains.List(ctx, ResourceTypeMailingList) {
 		rel = append(rel, FindResourceRequest{
 			Type: ResourceTypeMailingList,
 			Name: fmt.Sprintf("%s@%s", user, d),
 		})
 	}
-	for _, d := range be.GetAllowedDomains(ctx, ResourceTypeNewsletter) {
+	for _, d := range domains.List(ctx, ResourceTypeNewsletter) {
 		rel = append(rel, FindResourceRequest{
 			Type: ResourceTypeNewsletter,
 			Name: fmt.Sprintf("%s@%s", user, d),
@@ -323,19 +282,13 @@ func relatedEmails(ctx context.Context, be domainBackend, addr string) []FindRes
 func relatedWebsites(ctx context.Context, be domainBackend, value string) []FindResourceRequest {
 	// Ignore the parent domain (websites share a global namespace).
 	return []FindResourceRequest{
-		{
-			Type: ResourceTypeWebsite,
-			Name: value,
-		},
+		{Type: ResourceTypeWebsite, Name: value},
 	}
 }
 
 func relatedDomains(ctx context.Context, be domainBackend, value string) []FindResourceRequest {
 	return []FindResourceRequest{
-		{
-			Type: ResourceTypeDomain,
-			Name: value,
-		},
+		{Type: ResourceTypeDomain, Name: value},
 	}
 }
 
@@ -348,58 +301,14 @@ func (v *validationContext) isAllowedDomain(rtype string) ValidatorFunc {
 	}
 }
 
-func (v *validationContext) isAvailableEmailAddr() ValidatorFunc {
-	return func(ctx context.Context, value string) error {
-		rel := relatedEmails(ctx, v.domains, value)
-
-		// Run the presence check in a new transaction. Unavailability
-		// of the server results in a validation error (fail close).
-		tx, err := v.backend.NewTransaction()
-		if err != nil {
-			return err
-		}
-		// Errors will cause to consider the address unavailable.
-		if ok, _ := tx.HasAnyResource(ctx, rel); ok { // nolint
-			return errors.New("address unavailable")
-		}
-		return nil
-	}
-}
-
-func (v *validationContext) isAvailableDomain() ValidatorFunc {
-	return func(ctx context.Context, value string) error {
-		rel := relatedDomains(ctx, v.domains, value)
-
-		// Run the presence check in a new transaction. Unavailability
-		// of the server results in a validation error (fail close).
-		tx, err := v.backend.NewTransaction()
-		if err != nil {
-			return err
-		}
-		// Errors will cause to consider the resource unavailable.
-		if ok, _ := tx.HasAnyResource(ctx, rel); ok { // nolint
-			return errors.New("address unavailable")
-		}
-		return nil
-	}
-}
-
-func (v *validationContext) isAvailableWebsite() ValidatorFunc {
-	return func(ctx context.Context, value string) error {
-		rel := relatedWebsites(ctx, v.domains, value)
-
-		// Run the presence check in a new transaction. Unavailability
-		// of the server results in a validation error (fail close).
-		tx, err := v.backend.NewTransaction()
-		if err != nil {
-			return err
-		}
-		// Errors will cause to consider the resource unavailable.
-		if ok, _ := tx.HasAnyResource(ctx, rel); ok { // nolint
-			return errors.New("address unavailable")
-		}
-		return nil
+func isResourceAvailable(ctx context.Context, backend Backend, rsrcs []FindResourceRequest) bool {
+	tx, err := backend.NewTransaction()
+	if err != nil {
+		// Fail close, return "not available".
+		return false
 	}
+	ok, _ := tx.HasAnyResource(ctx, rsrcs)
+	return !ok
 }
 
 func (v *validationContext) isAvailableDAV() ValidatorFunc {
@@ -487,22 +396,22 @@ func (v *validationContext) validPassword() ValidatorFunc {
 	)
 }
 
-func allOf(funcs ...ValidatorFunc) ValidatorFunc {
-	return func(ctx context.Context, value string) error {
-		for _, f := range funcs {
-			if err := f(ctx, value); err != nil {
-				return err
-			}
-		}
-		return nil
-	}
+// ResourceValidator is a composite type validator that checks
+// various fields in a Resource, depending on its type.
+//
+// The availability check is separate and it performs the role of an
+// ACL check.
+type resourceValidator interface {
+	Validate(*RequestContext, *Resource, *User) error
+	IsAvailable(*RequestContext, *Resource, *User) bool
 }
 
-// ResourceValidatorFunc is a composite type validator that checks
-// various fields in a Resource, depending on its type.
-type ResourceValidatorFunc func(context.Context, *Resource, *User, bool) error
+type resourceValidatorBase struct {
+	backend Backend
+}
 
-func (v *validationContext) validateResource(_ context.Context, r *Resource, user *User) error {
+// Validate the common attributes of a Resource.
+func (v *resourceValidatorBase) Validate(_ *RequestContext, r *Resource, user *User) error {
 	// Validate the status enum.
 	switch r.Status {
 	case ResourceStatusActive, ResourceStatusInactive, ResourceStatusReadonly, ResourceStatusArchived:
@@ -524,19 +433,40 @@ func (v *validationContext) validateResource(_ context.Context, r *Resource, use
 	return nil
 }
 
-func (v *validationContext) validateShardedResource(ctx context.Context, r *Resource, user *User) error {
-	if err := v.validateResource(ctx, r, user); err != nil {
+// Helper function to check availability of one or more resources in
+// the database. We accept a list of resources as input because the
+// name spaces for different resources partially overlap (emails,
+// mailing lists, etc) so we often have to check multiple types.
+func (v *resourceValidatorBase) checkResources(ctx context.Context, rsrcs []FindResourcesRequest) bool {
+	tx, err := v.backend.NewTransaction()
+	if err != nil {
+		// Fail close on database errors.
+		return false
+	}
+	ok, _ := tx.HasAnyResource(ctx, rsrcs)
+	return ok
+}
+
+type shardedResourceValidatorBase struct {
+	*resourceValidatorBase
+	allowedShards validationDatasetMap
+}
+
+// Validate the attributes of a sharded resource.
+func (v *shardedResourceValidatorBase) Validate(rctx *ResourceContext, r *Resource, user *User) error {
+	if err := v.resourceValidatorBase.Validate(rctx, r, user); err != nil {
 		return err
 	}
+
 	if r.Shard == "" {
 		return errors.New("empty shard")
 	}
-	if !v.shards.IsAllowedShard(ctx, r.Type, r.Shard) {
+	if !allowedShards.Check(rctx, r.Type, r.Shard) {
 		return fmt.Errorf(
 			"invalid shard %s for resource type %s (allowed: %v)",
 			r.Shard,
 			r.Type,
-			v.shards.GetAllowedShards(ctx, r.Type),
+			allowedShards.List(rctx.Context(), r.Type),
 		)
 	}
 	if r.OriginalShard == "" {
@@ -545,99 +475,318 @@ func (v *validationContext) validateShardedResource(ctx context.Context, r *Reso
 	return nil
 }
 
-func (v *validationContext) validEmailResource() ResourceValidatorFunc {
-	emailValidator := v.validHostedEmail()
-	newEmailValidator := v.validHostedNewEmail()
+// Validator for Email resources.
+type emailResourceValidator struct {
+	*shardedResourceValidatorBase
 
-	return func(ctx context.Context, r *Resource, user *User, isNew bool) error {
-		if err := v.validateShardedResource(ctx, r, user); err != nil {
-			return err
-		}
+	maxUsernameLen     int
+	minUsernameLen     int
+	forbiddenUsernames validationDataset
+	allowedDomains     validationDataset
+}
 
-		// Email resources aren't nested.
-		if !r.ParentID.Empty() {
-			return errors.New("resource should not have parent")
-		}
+func (v *emailResourceValidator) Validate(rctx *ResourceContext, r *Resource, user *User) error {
+	if err := v.shardedResourceValidatorBase.Validate(rctx, r, user); err != nil {
+		return err
+	}
 
-		if r.Email == nil {
-			return errors.New("resource has no email metadata")
-		}
+	// Email resources shouldn't be nested.
+	if !r.ParentID.Empty() {
+		return errors.New("resource should not have parent")
+	}
 
-		var err error
-		if isNew {
-			err = newEmailValidator(ctx, r.Name)
-		} else {
-			err = emailValidator(ctx, r.Name)
-		}
-		if err != nil {
+	if r.Email == nil {
+		return errors.New("resource has no email metadata")
+	}
+	if r.Email.Maildir == "" {
+		return errors.New("empty maildir")
+	}
+
+	// Validate the resource email address field, and check for
+	// availability of the domain.
+	return validateUsernameAndDomain(
+		allOf(
+			matchUsernameRx(),
+			minLength(v.minUsernameLen),
+			maxLength(v.maxUsernameLen),
+			notInSet(v.forbiddenUsernames),
+		),
+		allOf(
+			inSet(v.allowedDomains),
+		),
+	)(rtcx, r.Name)
+}
+
+func (v *emailResourceValidator) IsAvailable(rctx *ResourceContext, r *Resource, user *User) bool {
+	// Emails share the same namespace as mailing lists and newsletters.
+	addr := r.Name
+	return v.checkResources([]FindResourceRequest{
+		{Type: ResourceTypeEmail, Name: addr},
+		{Type: ResourceTypeMailingList, Name: addr},
+		{Type: ResourceTypeNewsletter, Name: addr},
+	})
+}
+
+// Mailing lists and newsletters share the same validation logic.
+type listResourceValidatorBase struct {
+	*shardedResourceValidatorBase
+}
+
+func (v *listResourceValidatorBase) Validate(rctx *RequestContext, r *Resource, user *User) error {
+	if err := v.shardedResourceValidatorBase.Validate(rctx, r, user); err != nil {
+		return err
+	}
+
+	// List resources shouldn't be nested.
+	if !r.ParentID.Empty() {
+		return errors.New("resource should not have parent")
+	}
+
+	// Validate the list name.
+	return validateUsernameAndDomain(
+		allOf(
+			matchUsernameRx(),
+			minLength(v.minUsernameLen),
+			maxLength(v.maxUsernameLen),
+			notInSet(v.forbiddenUsernames),
+		),
+		inSet(v.allowedDomains),
+	)(rctx, r.Name)
+}
+
+// Helper to validate a list of admins.
+func (v *listResourceValidatorBase) validateAdmins(rctx *RequestContext, admins []string) error {
+	if len(admins) < 1 {
+		return errors.New("list has no admins")
+	}
+	for _, addr := range admins {
+		if err := validateEmailAddr(rctx, addr); err != nil {
 			return err
 		}
+	}
+	return nil
+}
 
-		if r.Email.Maildir == "" {
-			return errors.New("empty maildir")
-		}
-		return nil
+func (v *listResourceValidatorBase) IsAvailable(rctx *ResourceContext, r *Resource, _ *User) bool {
+	// Check for an email with the same address.
+	rel := []FindResourceRequest{
+		{Type: ResourceTypeEmail, Name: r.Name},
+	}
+
+	// Mailing lists and newsletters must have unique names
+	// regardless of the domain, so we add potential conflicts for
+	// mailing lists with the same name over all list-enabled
+	// domains.
+	user, _ := splitAddr(r.Name)
+	for _, d := range domains.List(ctx, ResourceTypeMailingList) {
+		rel = append(rel, FindResourceRequest{
+			Type: ResourceTypeMailingList,
+			Name: fmt.Sprintf("%s@%s", user, d),
+		})
+	}
+	for _, d := range domains.List(ctx, ResourceTypeNewsletter) {
+		rel = append(rel, FindResourceRequest{
+			Type: ResourceTypeNewsletter,
+			Name: fmt.Sprintf("%s@%s", user, d),
+		})
 	}
+	return v.checkResources(rel)
 }
 
-func (v *validationContext) validListResource() ResourceValidatorFunc {
-	listValidator := v.validHostedMailingList()
-	newListValidator := allOf(listValidator, v.isAvailableEmailAddr())
+// Validator for MailingList resources.
+type mailingListResourceValidator struct {
+	*listResourceValidatorBase
+}
 
-	return func(ctx context.Context, r *Resource, user *User, isNew bool) error {
-		if err := v.validateShardedResource(ctx, r, user); err != nil {
-			return err
-		}
-		if r.List == nil {
-			return errors.New("resource has no list metadata")
-		}
+func (v *mailingListResourceValidator) Validate(rctx *RequestContext, r *Resource, user *User) error {
+	if err := v.listResourceValidatorBase.Validate(rctx, r, user); err != nil {
+		return err
+	}
 
-		var err error
-		if isNew {
-			err = newListValidator(ctx, r.Name)
-		} else {
-			err = listValidator(ctx, r.Name)
-		}
-		if err != nil {
-			return err
-		}
+	if r.List == nil {
+		return errors.New("resource has no list metadata")
+	}
+	return v.validateAdmins(rctx, r.List.Admins)
+}
 
-		if len(r.List.Admins) < 1 {
-			return errors.New("can't create a list without admins")
-		}
-		return nil
+// Validator for Newsletter resources.
+type newsletterResourceValidator struct {
+	*listResourceValidatorBase
+}
+
+func (v *newsletterResourceValidator) Validate(rctx *RequestContext, r *Resource, user *User) error {
+	if err := v.listResourceValidatorBase.Validate(rctx, r, user); err != nil {
+		return err
 	}
+
+	if r.Newsletter == nil {
+		return errors.New("resource has no newsletter metadata")
+	}
+	return r.validateAdmins(rctx, r.Newsletter.Admins)
 }
 
-func (v *validationContext) validNewsletterResource() ResourceValidatorFunc {
-	listValidator := v.validHostedMailingList()
-	newListValidator := allOf(listValidator, v.isAvailableEmailAddr())
+// Validator helper type for resources that have a UNIX user ID.
+type uidResourceValidatorMixin struct {
+	minUID, maxUID int
+}
 
-	return func(ctx context.Context, r *Resource, user *User, isNew bool) error {
-		if err := v.validateShardedResource(ctx, r, user); err != nil {
-			return err
-		}
-		if r.Newsletter == nil {
-			return errors.New("resource has no newsletter metadata")
-		}
+func (v *uidResourceValidatorMixin) validateUID(uid int, user *User) error {
+	if uid == 0 {
+		return errors.New("resource UID is not set")
+	}
+	if uid < v.minUID || (v.maxUID > 0 && uid > v.maxUID) {
+		return fmt.Errorf("resource UID %d outside of allowed range (%d-%d)", uid, v.minUID, v.maxUID)
+	}
+	if user != nil && uid != user.UID {
+		return errors.New("resource UID differs from user UID")
+	}
+	return nil
+}
 
-		var err error
-		if isNew {
-			err = newListValidator(ctx, r.Name)
-		} else {
-			err = listValidator(ctx, r.Name)
-		}
-		if err != nil {
-			return err
-		}
+// Base validator type for web resources.
+type webResourceValidatorBase struct {
+	*shardedResourceValidatorBase
+	*uidResourceValidatorMixin
 
-		if len(r.Newsletter.Admins) < 1 {
-			return errors.New("can't create a newsletter without admins")
-		}
-		return nil
+	webroot string
+}
+
+func (v *webResourceValidatorBase) Validate(rctx *RequestContext, r *Resource, user *User) error {
+	if err := v.shardedResourceValidatorBase.Validate(rctx, r, user); err != nil {
+		return err
+	}
+
+	// Web resources shouldn't be nested.
+	if !r.ParentID.Empty() {
+		return errors.New("resource should not have parent")
+	}
+
+	if r.Website == nil {
+		return errors.New("resource has no website metadata")
+	}
+
+	// Document root checks: must not be empty, and the
+	// user must have a DAV account with a home directory
+	// that is a parent of this document root.
+	if r.Website.DocumentRoot == "" {
+		return errors.New("empty document_root")
+	}
+	if !isSubdir(v.webroot, r.Website.DocumentRoot) {
+		return errors.New("document root outside of web root")
+	}
+	if !hasMatchingDAVAccount(user, r) {
+		return errors.New("website has no matching DAV account")
 	}
+
+	return v.uidResourceValidatorMixin.validateUID(r.Website.UID, user)
+}
+
+type websiteResourceValidator struct {
+	*webResourceValidatorBase
+
+	allowedDomains     validatorDataset
+	forbiddenSiteNames validatorDataset
+	minNameLen         int
+}
+
+func (v *websiteResourceValidator) Validate(rctx *RequestContext, r *Resource, user *User) error {
+	if err := v.webResourceValidatorBase.Validate(rctx, r, user); err != nil {
+		return err
+	}
+
+	if err := inSet(v.allowedDomains)(rctx, r.Website.ParentDomain); err != nil {
+		return err
+	}
+	if err := allOf(
+		matchSitenameRx(),
+		minLength(v.minNameLen),
+		notInSet(v.forbiddenSiteNames),
+	)(rctx, r.Name); err != nil {
+		return err
+	}
+
+	return nil
 }
 
+func (v *websiteResourceValidatorBase) IsAvailable(rctx *ResourceContext, r *Resource, _ *User) bool {
+}
+
+type domainResourceValidator struct {
+	*webResourceValidatorBase
+
+	forbiddenDomains validationDataset
+}
+
+func (v *domainResourceValidator) Validate(rctx *RequestContext, r *Resource, user *User) error {
+	if err := v.webResourceValidatorBase.Validate(rctx, r, user); err != nil {
+		return err
+	}
+
+	if r.Website.ParentDomain != "" {
+		return errors.New("non-empty parent_domain on domain resource")
+	}
+
+	if err := allOf(
+		matchDomainRx(),
+		notInSet(v.forbiddenDomains),
+	)(rctx, r.Name); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (v *domainResourceValidatorBase) IsAvailable(rctx *ResourceContext, r *Resource, _ *User) bool {
+}
+
+type davResourceValidator struct {
+	*shardedResourceValidatorBase
+	*uidResourceValidatorMixin
+
+	minLen  int
+	webroot string
+}
+
+func (v *davResourceValidator) Validate(rctx *RequestContext, r *Resource, user *User) error {
+	// return firstErr(
+	// 	v.shardedResourceValidatorBase.Validate(rctx, r, user),
+	// 	noParentID,
+	// 	notNil(r.DAV),
+	// 	validateField(r.Name, allOf(
+	// 		minLength(v.minLen),
+	// 		matchIdentifierRx(),
+	// 	)),
+	// 	subdirOf(v.webroot, r.DAV.Homedir),
+	// )
+
+	if err := v.shardedResourceValidatorBase.Validate(rctx, r, user); err != nil {
+		return err
+	}
+
+	// DAV resources shouldn't be nested.
+	if !r.ParentID.Empty() {
+		return errors.New("resource should not have parent")
+	}
+
+	if r.DAV == nil {
+		return errors.New("resource has no DAV metadata")
+	}
+
+	if err := allOf(
+		minLength(v.minLen),
+		matchIdentifierRx(),
+	)(rctx, r.Name); err != nil {
+		return err
+	}
+	if !isSubdir(v.webroot, r.DAV.Homedir) {
+		return errors.New("homedir outside of web root")
+	}
+
+	return v.uidResourceValidatorMixin.validateUID(r.DAV.UID, user)
+}
+
+///////////////////// CUT THE CRAP HERE /////////////////////////
+
 func findMatchingDAVAccount(user *User, r *Resource) *Resource {
 	for _, dav := range user.GetResourcesByType(ResourceTypeDAV) {
 		if isSubdir(dav.DAV.Homedir, r.Website.DocumentRoot) {
@@ -966,214 +1115,6 @@ func (v *validationContext) validUser() UserValidatorFunc {
 	}
 }
 
-type templateContext struct {
-	shards  shardBackend
-	webroot string
-}
-
-func (c *templateContext) pickShard(ctx context.Context, r *Resource) (string, error) {
-	avail := c.shards.GetAvailableShards(ctx, r.Type)
-	if len(avail) == 0 {
-		return "", fmt.Errorf("no available shards for resource type %s", r.Type)
-	}
-	return avail[rand.Intn(len(avail))], nil
-}
-
-func (c *templateContext) setResourceShard(ctx context.Context, r *Resource, ref *Resource) error {
-	if r.Shard == "" {
-		if ref != nil {
-			r.Shard = ref.Shard
-		} else {
-			s, err := c.pickShard(ctx, r)
-			if err != nil {
-				return err
-			}
-			r.Shard = s
-		}
-	}
-	if r.OriginalShard == "" {
-		r.OriginalShard = r.Shard
-	}
-	return nil
-}
-
-func (c *templateContext) setResourceStatus(r *Resource) {
-	if r.Status == "" {
-		r.Status = ResourceStatusActive
-	}
-}
-
-func (c *templateContext) setCommonResourceAttrs(ctx context.Context, r *Resource, ref *Resource, user *User) error {
-	// If we reference another resource, ensure it has been templated.
-	if ref != nil {
-		if err := c.applyTemplate(ctx, ref, user); err != nil {
-			return err
-		}
-	}
-
-	r.CreatedAt = time.Now().UTC().Format("2006-01-02")
-
-	c.setResourceStatus(r)
-	return c.setResourceShard(ctx, r, ref)
-}
-
-// Apply default values to an Email resource.
-func (c *templateContext) emailResourceTemplate(ctx context.Context, r *Resource, _ *User) error {
-	// Force the email address to lowercase.
-	r.Name = strings.ToLower(r.Name)
-
-	if r.Email == nil {
-		r.Email = new(Email)
-	}
-
-	addrParts := strings.Split(r.Name, "@")
-	if len(addrParts) != 2 {
-		return errors.New("malformed name")
-	}
-	r.Email.Maildir = fmt.Sprintf("%s/%s", addrParts[1], addrParts[0])
-	r.Email.QuotaLimit = 4096
-	return c.setCommonResourceAttrs(ctx, r, nil, nil)
-}
-
-// Apply default values to a Website or Domain resource.
-func (c *templateContext) websiteResourceTemplate(ctx context.Context, r *Resource, user *User) error {
-	if user == nil {
-		return errors.New("website resource needs owner")
-	}
-
-	// Force the website address to lowercase.
-	r.Name = strings.ToLower(r.Name)
-
-	if r.Website == nil {
-		r.Website = new(Website)
-	}
-
-	// If the client did not specify a DocumentRoot, find a DAV resource
-	// and associate the website with it.
-	if r.Website.DocumentRoot == "" {
-		dav := user.GetSingleResourceByType(ResourceTypeDAV)
-		if dav == nil {
-			return errors.New("user has no DAV accounts")
-		}
-
-		// The DAV resource may not have been templatized yet.
-		if dav.DAV == nil || dav.DAV.Homedir == "" {
-			if err := c.davResourceTemplate(ctx, dav, user); err != nil {
-				return err
-			}
-		}
-		r.Website.DocumentRoot = filepath.Join(dav.DAV.Homedir, "html-"+r.Name)
-	}
-	r.Website.DocumentRoot = filepath.Clean(r.Website.DocumentRoot)
-
-	if len(r.Website.Options) == 0 {
-		r.Website.Options = []string{"nomail"}
-	}
-
-	r.Website.UID = user.UID
-
-	dav := findMatchingDAVAccount(user, r)
-	if dav == nil {
-		return fmt.Errorf("no DAV resources matching website %s", r.String())
-	}
-	return c.setCommonResourceAttrs(ctx, r, dav, user)
-}
-
-// Apply default values to a DAV resource.
-func (c *templateContext) davResourceTemplate(ctx context.Context, r *Resource, user *User) error {
-	if user == nil {
-		return errors.New("dav resource needs owner")
-	}
-
-	// Force the account name to lowercase.
-	r.Name = strings.ToLower(r.Name)
-
-	if r.DAV == nil {
-		r.DAV = new(WebDAV)
-	}
-	if r.DAV.Homedir == "" {
-		r.DAV.Homedir = filepath.Join(c.webroot, r.Name)
-	}
-	r.DAV.Homedir = filepath.Clean(r.DAV.Homedir)
-	r.DAV.UID = user.UID
-
-	return c.setCommonResourceAttrs(ctx, r, nil, user)
-}
-
-// Apply default values to a Database resource.
-func (c *templateContext) databaseResourceTemplate(ctx context.Context, r *Resource, user *User) error {
-	if user == nil {
-		return errors.New("database resource needs owner")
-	}
-
-	// Force the database name to lowercase.
-	r.Name = strings.ToLower(r.Name)
-
-	if r.Database == nil {
-		r.Database = new(Database)
-	}
-	if r.Database.DBUser == "" {
-		r.Database.DBUser = r.Name
-	}
-
-	return c.setCommonResourceAttrs(ctx, r, user.GetResourceByID(r.ParentID), user)
-}
-
-// Apply default values to a MailingList resource.
-func (c *templateContext) listResourceTemplate(ctx context.Context, r *Resource, user *User) error {
-	// Force the list address to lowercase.
-	r.Name = strings.ToLower(r.Name)
-
-	if r.List == nil {
-		r.List = new(MailingList)
-	}
-
-	// As a convenience, if a user is passed in the context, we add it to
-	// the list admins.
-	if user != nil && len(r.List.Admins) == 0 {
-		r.List.Admins = []string{user.Name}
-	}
-
-	return c.setCommonResourceAttrs(ctx, r, nil, nil)
-}
-
-// Apply default values to a Newsletter resource.
-func (c *templateContext) newsletterResourceTemplate(ctx context.Context, r *Resource, user *User) error {
-	// Force the list address to lowercase.
-	r.Name = strings.ToLower(r.Name)
-
-	if r.Newsletter == nil {
-		r.Newsletter = new(Newsletter)
-	}
-
-	// As a convenience, if a user is passed in the context, we add it to
-	// the list admins.
-	if user != nil && len(r.Newsletter.Admins) == 0 {
-		r.Newsletter.Admins = []string{user.Name}
-	}
-
-	return c.setCommonResourceAttrs(ctx, r, nil, nil)
-}
-
-// Apply default values to a resource.
-func (c *templateContext) applyTemplate(ctx context.Context, r *Resource, user *User) error {
-	switch r.Type {
-	case ResourceTypeEmail:
-		return c.emailResourceTemplate(ctx, r, user)
-	case ResourceTypeWebsite, ResourceTypeDomain:
-		return c.websiteResourceTemplate(ctx, r, user)
-	case ResourceTypeDAV:
-		return c.davResourceTemplate(ctx, r, user)
-	case ResourceTypeDatabase:
-		return c.databaseResourceTemplate(ctx, r, user)
-	case ResourceTypeMailingList:
-		return c.listResourceTemplate(ctx, r, user)
-	case ResourceTypeNewsletter:
-		return c.newsletterResourceTemplate(ctx, r, user)
-	}
-	return nil
-}
-
 func isSubdir(root, dir string) bool {
 	return strings.HasPrefix(dir, root+"/")
 }
-- 
GitLab