diff --git a/aliasdomain.go b/aliasdomain.go new file mode 100644 index 0000000..165ebf2 --- /dev/null +++ b/aliasdomain.go @@ -0,0 +1,34 @@ +package mailfull + +// AliasDomain represents a AliasDomain. +type AliasDomain struct { + name string + target string +} + +// NewAliasDomain creates a new AliasDomain instance. +func NewAliasDomain(name, target string) (*AliasDomain, error) { + if !validAliasDomainName(name) { + return nil, ErrInvalidAliasDomainName + } + if !validAliasDomainTarget(target) { + return nil, ErrInvalidAliasDomainTarget + } + + ad := &AliasDomain{ + name: name, + target: target, + } + + return ad, nil +} + +// Name returns name. +func (ad *AliasDomain) Name() string { + return ad.name +} + +// Target returns target. +func (ad *AliasDomain) Target() string { + return ad.target +} diff --git a/aliasuser.go b/aliasuser.go new file mode 100644 index 0000000..7b04696 --- /dev/null +++ b/aliasuser.go @@ -0,0 +1,48 @@ +package mailfull + +import "errors" + +// Errors for parameter. +var ( + ErrNotEnoughAliasUserTargets = errors.New("AliasUser: targets not enough") +) + +// AliasUser represents a AliasUser. +type AliasUser struct { + name string + targets []string +} + +// NewAliasUser creates a new AliasUser instance. +func NewAliasUser(name string, targets []string) (*AliasUser, error) { + if !validAliasUserName(name) { + return nil, ErrInvalidAliasUserName + } + + if len(targets) < 1 { + return nil, ErrNotEnoughAliasUserTargets + } + + for _, target := range targets { + if !validAliasUserTarget(target) { + return nil, ErrInvalidAliasUserTarget + } + } + + au := &AliasUser{ + name: name, + targets: targets, + } + + return au, nil +} + +// Name returns name. +func (au *AliasUser) Name() string { + return au.name +} + +// Targets returns targets. +func (au *AliasUser) Targets() []string { + return au.targets +} diff --git a/catchalluser.go b/catchalluser.go new file mode 100644 index 0000000..61e93bf --- /dev/null +++ b/catchalluser.go @@ -0,0 +1,24 @@ +package mailfull + +// CatchAllUser represents a CatchAllUser. +type CatchAllUser struct { + name string +} + +// NewCatchAllUser creates a new CatchAllUser instance. +func NewCatchAllUser(name string) (*CatchAllUser, error) { + if !validCatchAllUserName(name) { + return nil, ErrInvalidCatchAllUserName + } + + cu := &CatchAllUser{ + name: name, + } + + return cu, nil +} + +// Name returns name. +func (cu *CatchAllUser) Name() string { + return cu.name +} diff --git a/const.go b/const.go new file mode 100644 index 0000000..98164a8 --- /dev/null +++ b/const.go @@ -0,0 +1,13 @@ +package mailfull + +// Filenames that are contained in the Repository. +const ( + DirNameConfig = ".mailfull" + FileNameConfig = "config" + + FileNameAliasDomains = ".valiasdomains" + FileNameUsersPassword = ".vpasswd" + FileNameUserForwards = ".forward" + FileNameAliasUsers = ".valiases" + FileNameCatchAllUser = ".vcatchall" +) diff --git a/domain.go b/domain.go new file mode 100644 index 0000000..1ca5d37 --- /dev/null +++ b/domain.go @@ -0,0 +1,27 @@ +package mailfull + +// Domain represents a Domain. +type Domain struct { + name string + Users []*User + AliasUsers []*AliasUser + CatchAllUser *CatchAllUser +} + +// NewDomain creates a new Domain instance. +func NewDomain(name string) (*Domain, error) { + if !validDomainName(name) { + return nil, ErrInvalidDomainName + } + + d := &Domain{ + name: name, + } + + return d, nil +} + +// Name returns name. +func (d *Domain) Name() string { + return d.name +} diff --git a/repository.go b/repository.go new file mode 100644 index 0000000..0763019 --- /dev/null +++ b/repository.go @@ -0,0 +1,399 @@ +package mailfull + +import ( + "bufio" + "errors" + "io/ioutil" + "os" + "path/filepath" + "strings" + "syscall" +) + +// Errors for the operation of the Repository. +var ( + ErrDomainNotExist = errors.New("Domain: not exist") + ErrUserNotExist = errors.New("User: not exist") + + ErrInvalidFormatUsersPassword = errors.New("User: password file invalid format") + ErrInvalidFormatAliasDomain = errors.New("AliasDomain: file invalid format") + ErrInvalidFormatAliasUsers = errors.New("AliasUsers: file invalid format") +) + +// Repository represents a Repository. +type Repository struct { + *RepositoryConfig +} + +// NewRepository creates a new Repository instance. +func NewRepository(c *RepositoryConfig) (*Repository, error) { + r := &Repository{ + RepositoryConfig: c, + } + + return r, nil +} + +// Domains returns a Domain slice. +func (r *Repository) Domains() ([]*Domain, error) { + fileInfos, err := ioutil.ReadDir(r.DirMailDataPath) + if err != nil { + return nil, err + } + + domains := make([]*Domain, 0, len(fileInfos)) + + for _, fileInfo := range fileInfos { + if !fileInfo.IsDir() { + continue + } + + name := fileInfo.Name() + + domain, err := NewDomain(name) + if err != nil { + continue + } + + domains = append(domains, domain) + } + + return domains, nil +} + +// Domain returns a Domain of the input name. +func (r *Repository) Domain(domainName string) (*Domain, error) { + if !validDomainName(domainName) { + return nil, ErrInvalidDomainName + } + + fileInfo, err := os.Stat(filepath.Join(r.DirMailDataPath, domainName)) + if err != nil { + return nil, err + } + + if !fileInfo.IsDir() { + return nil, nil + } + + name := domainName + + domain, err := NewDomain(name) + if err != nil { + return nil, err + } + + return domain, nil +} + +// AliasDomains returns a AliasDomain slice. +func (r *Repository) AliasDomains() ([]*AliasDomain, error) { + file, err := os.Open(filepath.Join(r.DirMailDataPath, FileNameAliasDomains)) + if err != nil { + return nil, err + } + + aliasDomains := make([]*AliasDomain, 0, 10) + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + words := strings.Split(scanner.Text(), ":") + if len(words) != 2 { + return nil, ErrInvalidFormatAliasDomain + } + + name := words[0] + target := words[1] + + aliasDomain, err := NewAliasDomain(name, target) + if err != nil { + return nil, err + } + + aliasDomains = append(aliasDomains, aliasDomain) + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return aliasDomains, nil +} + +// AliasDomain returns a AliasDomain of the input name. +func (r *Repository) AliasDomain(aliasDomainName string) (*AliasDomain, error) { + aliasDomains, err := r.AliasDomains() + if err != nil { + return nil, err + } + + for _, aliasDomain := range aliasDomains { + if aliasDomain.Name() == aliasDomainName { + return aliasDomain, nil + } + } + + return nil, nil +} + +// Users returns a User slice. +func (r *Repository) Users(domainName string) ([]*User, error) { + domain, err := r.Domain(domainName) + if err != nil { + return nil, err + } + if domain == nil { + return nil, ErrDomainNotExist + } + + hashedPasswords, err := r.usersHashedPassword(domainName) + if err != nil { + return nil, err + } + + fileInfos, err := ioutil.ReadDir(filepath.Join(r.DirMailDataPath, domainName)) + if err != nil { + return nil, err + } + + users := make([]*User, 0, len(fileInfos)) + + for _, fileInfo := range fileInfos { + if !fileInfo.IsDir() { + continue + } + + name := fileInfo.Name() + + forwards, err := r.userForwards(domainName, name) + if err != nil { + return nil, err + } + + hashedPassword, ok := hashedPasswords[name] + if !ok { + hashedPassword = "" + } + + user, err := NewUser(name, hashedPassword, forwards) + if err != nil { + continue + } + + users = append(users, user) + } + + return users, nil +} + +// User returns a User of the input name. +func (r *Repository) User(domainName, userName string) (*User, error) { + domain, err := r.Domain(domainName) + if err != nil { + return nil, err + } + if domain == nil { + return nil, ErrDomainNotExist + } + + if !validUserName(userName) { + return nil, ErrInvalidUserName + } + + hashedPasswords, err := r.usersHashedPassword(domainName) + if err != nil { + return nil, err + } + + fileInfo, err := os.Stat(filepath.Join(r.DirMailDataPath, domainName, userName)) + if err != nil { + return nil, err + } + + if !fileInfo.IsDir() { + return nil, nil + } + + name := userName + + forwards, err := r.userForwards(domainName, name) + if err != nil { + return nil, err + } + + hashedPassword, ok := hashedPasswords[name] + if !ok { + hashedPassword = "" + } + + user, err := NewUser(name, hashedPassword, forwards) + if err != nil { + return nil, err + } + + return user, nil +} + +// usersHashedPassword returns a string map of usernames to the hashed password. +func (r *Repository) usersHashedPassword(domainName string) (map[string]string, error) { + domain, err := r.Domain(domainName) + if err != nil { + return nil, err + } + if domain == nil { + return nil, ErrDomainNotExist + } + + file, err := os.Open(filepath.Join(r.DirMailDataPath, domainName, FileNameUsersPassword)) + if err != nil { + return nil, err + } + + hashedPasswords := map[string]string{} + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + words := strings.Split(scanner.Text(), ":") + if len(words) != 2 { + return nil, ErrInvalidFormatUsersPassword + } + + name := words[0] + hashedPassword := words[1] + + hashedPasswords[name] = hashedPassword + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return hashedPasswords, nil +} + +// userForwards returns a string slice of forwards that the input name has. +func (r *Repository) userForwards(domainName, userName string) ([]string, error) { + user, err := r.User(domainName, userName) + if err != nil { + return nil, err + } + if user == nil { + return nil, ErrUserNotExist + } + + file, err := os.Open(filepath.Join(r.DirMailDataPath, domainName, userName, FileNameUserForwards)) + if err != nil { + if err.(*os.PathError).Err == syscall.ENOENT { + return []string{}, nil + } + + return nil, err + } + + forwards := make([]string, 0, 5) + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + forwards = append(forwards, scanner.Text()) + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return forwards, nil +} + +// AliasUsers returns a AliasUser slice. +func (r *Repository) AliasUsers(domainName string) ([]*AliasUser, error) { + domain, err := r.Domain(domainName) + if err != nil { + return nil, err + } + if domain == nil { + return nil, ErrDomainNotExist + } + + file, err := os.Open(filepath.Join(r.DirMailDataPath, domainName, FileNameAliasUsers)) + if err != nil { + return nil, err + } + + aliasUsers := make([]*AliasUser, 0, 50) + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + words := strings.Split(scanner.Text(), ":") + if len(words) != 2 { + return nil, ErrInvalidFormatAliasUsers + } + + name := words[0] + targets := strings.Split(words[1], ",") + + aliasUser, err := NewAliasUser(name, targets) + if err != nil { + return nil, err + } + + aliasUsers = append(aliasUsers, aliasUser) + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return aliasUsers, nil +} + +// AliasUser returns a AliasUser of the input name. +func (r *Repository) AliasUser(domainName, aliasUserName string) (*AliasUser, error) { + aliasUsers, err := r.AliasUsers(domainName) + if err != nil { + return nil, err + } + + for _, aliasUser := range aliasUsers { + if aliasUser.Name() == aliasUserName { + return aliasUser, nil + } + } + + return nil, nil +} + +// CatchAllUser returns a CatchAllUser that the input name has. +func (r *Repository) CatchAllUser(domainName string) (*CatchAllUser, error) { + domain, err := r.Domain(domainName) + if err != nil { + return nil, err + } + if domain == nil { + return nil, ErrDomainNotExist + } + + file, err := os.Open(filepath.Join(r.DirMailDataPath, domainName, FileNameCatchAllUser)) + if err != nil { + return nil, err + } + + scanner := bufio.NewScanner(file) + scanner.Scan() + + name := scanner.Text() + + if err := scanner.Err(); err != nil { + return nil, err + } + + if name == "" { + return nil, nil + } + + catchAllUser, err := NewCatchAllUser(name) + if err != nil { + return nil, err + } + + return catchAllUser, nil +} diff --git a/repositoryconfig.go b/repositoryconfig.go new file mode 100644 index 0000000..32bf2af --- /dev/null +++ b/repositoryconfig.go @@ -0,0 +1,214 @@ +package mailfull + +import ( + "errors" + "os" + "path/filepath" + "syscall" + + "github.com/BurntSushi/toml" +) + +// Errors for the Repository. +var ( + ErrInvalidRepository = errors.New("invalid repository") + ErrNotRepository = errors.New("not a Mailfull repository (or any of the parent directories)") + ErrRepositoryExist = errors.New("a Mailfull repository exists") +) + +// RepositoryConfig is used to configure a Repository. +type RepositoryConfig struct { + DirDatabasePath string `toml:"dir_database"` + DirMailDataPath string `toml:"dir_maildata"` + Username string `toml:"username"` + Groupname string `toml:"groupname"` +} + +// DefaultRepositoryConfig returns a RepositoryConfig with default parameter. +func DefaultRepositoryConfig() *RepositoryConfig { + c := &RepositoryConfig{ + DirDatabasePath: "./etc", + DirMailDataPath: "./domains", + Username: "mailfull", + Groupname: "mailfull", + } + + return c +} + +// OpenRepository opens a Repository and creates a new Repository instance. +func OpenRepository(basePath string) (*Repository, error) { + rootPath, err := filepath.Abs(basePath) + if err != nil { + return nil, err + } + + for { + configDirPath := filepath.Join(rootPath, DirNameConfig) + + fi, errStat := os.Stat(configDirPath) + if errStat != nil { + if errStat.(*os.PathError).Err != syscall.ENOENT { + return nil, errStat + } + } else { + if fi.IsDir() { + break + } else { + return nil, ErrInvalidRepository + } + } + + parentPath := filepath.Clean(filepath.Join(rootPath, "..")) + if rootPath == parentPath { + return nil, ErrNotRepository + } + rootPath = parentPath + } + + configFilePath := filepath.Join(rootPath, DirNameConfig, FileNameConfig) + + fi, err := os.Stat(configFilePath) + if err != nil { + return nil, err + } + if fi.IsDir() { + return nil, ErrInvalidRepository + } + + configFile, err := os.Open(configFilePath) + if err != nil { + return nil, err + } + defer configFile.Close() + + c := DefaultRepositoryConfig() + if _, err = toml.DecodeReader(configFile, c); err != nil { + return nil, err + } + + if !filepath.IsAbs(c.DirDatabasePath) { + c.DirDatabasePath = filepath.Join(rootPath, c.DirDatabasePath) + } + if !filepath.IsAbs(c.DirMailDataPath) { + c.DirMailDataPath = filepath.Join(rootPath, c.DirMailDataPath) + } + + r, err := NewRepository(c) + if err != nil { + return nil, err + } + + return r, nil +} + +// InitRepository initializes the input directory as a Repository. +func InitRepository(rootPath string) error { + rootPath, err := filepath.Abs(rootPath) + if err != nil { + return err + } + + configDirPath := filepath.Join(rootPath, DirNameConfig) + + fi, err := os.Stat(configDirPath) + if err != nil { + if err.(*os.PathError).Err == syscall.ENOENT { + if err = os.Mkdir(configDirPath, 0777); err != nil { + return err + } + } else { + return err + } + } else { + if !fi.IsDir() { + return ErrInvalidRepository + } + } + + configFilePath := filepath.Join(configDirPath, FileNameConfig) + + fi, err = os.Stat(configFilePath) + if err != nil { + if err.(*os.PathError).Err != syscall.ENOENT { + return err + } + } else { + if fi.IsDir() { + return ErrInvalidRepository + } + + return ErrRepositoryExist + } + + configFile, err := os.Create(configFilePath) + if err != nil { + return nil + } + defer configFile.Close() + + c := DefaultRepositoryConfig() + + enc := toml.NewEncoder(configFile) + if err := enc.Encode(c); err != nil { + return err + } + + if !filepath.IsAbs(c.DirDatabasePath) { + c.DirDatabasePath = filepath.Join(rootPath, c.DirDatabasePath) + } + if !filepath.IsAbs(c.DirMailDataPath) { + c.DirMailDataPath = filepath.Join(rootPath, c.DirMailDataPath) + } + + fi, err = os.Stat(c.DirDatabasePath) + if err != nil { + if err.(*os.PathError).Err == syscall.ENOENT { + if err = os.Mkdir(c.DirDatabasePath, 0777); err != nil { + return err + } + } else { + return err + } + } else { + if !fi.IsDir() { + return ErrInvalidRepository + } + } + + fi, err = os.Stat(c.DirMailDataPath) + if err != nil { + if err.(*os.PathError).Err == syscall.ENOENT { + if err = os.Mkdir(c.DirMailDataPath, 0777); err != nil { + return err + } + } else { + return err + } + } else { + if !fi.IsDir() { + return ErrInvalidRepository + } + } + + aliasDomainFileName := filepath.Join(c.DirMailDataPath, FileNameAliasDomains) + + fi, err = os.Stat(aliasDomainFileName) + if err != nil { + if err.(*os.PathError).Err != syscall.ENOENT { + return err + } + } else { + if fi.IsDir() { + return ErrInvalidRepository + } + } + + aliasDomainFile, err := os.Create(aliasDomainFileName) + if err != nil { + return nil + } + defer aliasDomainFile.Close() + + return nil +} diff --git a/user.go b/user.go new file mode 100644 index 0000000..5899cc0 --- /dev/null +++ b/user.go @@ -0,0 +1,38 @@ +package mailfull + +// User represents a User. +type User struct { + name string + hashedPassword string + forwards []string +} + +// NewUser creates a new User instance. +func NewUser(name, hashedPassword string, forwards []string) (*User, error) { + if !validUserName(name) { + return nil, ErrInvalidUserName + } + + u := &User{ + name: name, + hashedPassword: hashedPassword, + forwards: forwards, + } + + return u, nil +} + +// Name returns name. +func (u *User) Name() string { + return u.name +} + +// HashedPassword returns hashedPassword. +func (u *User) HashedPassword() string { + return u.hashedPassword +} + +// Forwards returns forwards. +func (u *User) Forwards() []string { + return u.forwards +} diff --git a/valid.go b/valid.go new file mode 100644 index 0000000..f2e828a --- /dev/null +++ b/valid.go @@ -0,0 +1,52 @@ +package mailfull + +import ( + "errors" + "regexp" +) + +// Errors for incorrect format. +var ( + ErrInvalidDomainName = errors.New("Domain: name incorrect format") + ErrInvalidAliasDomainName = errors.New("AliasDomain: name incorrect format") + ErrInvalidAliasDomainTarget = errors.New("AliasDomain: target incorrect format") + ErrInvalidUserName = errors.New("User: name incorrect format") + ErrInvalidAliasUserName = errors.New("AliasUser: name incorrect format") + ErrInvalidAliasUserTarget = errors.New("AliasUser: target incorrect format") + ErrInvalidCatchAllUserName = errors.New("CatchAllUser: name incorrect format") +) + +// validDomainName returns true if the input is correct format. +func validDomainName(name string) bool { + return regexp.MustCompile(`^([A-Za-z0-9][A-Za-z0-9\-]{1,61}[A-Za-z0-9]\.)*[A-Za-z]+$`).MatchString(name) +} + +// validAliasDomainName returns true if the input is correct format. +func validAliasDomainName(name string) bool { + return regexp.MustCompile(`^([A-Za-z0-9][A-Za-z0-9\-]{1,61}[A-Za-z0-9]\.)*[A-Za-z]+$`).MatchString(name) +} + +// validAliasDomainTarget returns true if the input is correct format. +func validAliasDomainTarget(target string) bool { + return regexp.MustCompile(`^([A-Za-z0-9][A-Za-z0-9\-]{1,61}[A-Za-z0-9]\.)*[A-Za-z]+$`).MatchString(target) +} + +// validUserName returns true if the input is correct format. +func validUserName(name string) bool { + return regexp.MustCompile(`^[^\s]+$`).MatchString(name) +} + +// validAliasUserName returns true if the input is correct format. +func validAliasUserName(name string) bool { + return regexp.MustCompile(`^[^\s]+$`).MatchString(name) +} + +// validAliasUserTarget returns true if the input is correct format. +func validAliasUserTarget(target string) bool { + return regexp.MustCompile(`^[^\s]+@([A-Za-z0-9][A-Za-z0-9\-]{1,61}[A-Za-z0-9]\.)*[A-Za-z]+$`).MatchString(target) +} + +// validCatchAllUserName returns true if the input is correct format. +func validCatchAllUserName(name string) bool { + return regexp.MustCompile(`^[^\s]+$`).MatchString(name) +} diff --git a/version.go b/version.go new file mode 100644 index 0000000..d2f4db1 --- /dev/null +++ b/version.go @@ -0,0 +1,4 @@ +package mailfull + +// Version is a version number. +const Version = "0.0.0"