commit 9972005cb5e558b491376a5a641e980957d0bbbf Author: Timofey.Kovalev Date: Tue Oct 14 10:35:42 2025 +0300 first commit diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0ec7796 --- /dev/null +++ b/Makefile @@ -0,0 +1,15 @@ +all: build/qmail + +DEPENDS := go.sum \ + cmd/main.go \ + internal/config/*.go \ + internal/mail/*.go + +go.sum: + go mod tidy + +build/qmail: $(DEPENDS) + CGO_ENABLED=0 go build -o build/qmail cmd/main.go + +install: build/qmail + install -Dm755 build/qmail /usr/local/bin diff --git a/README.md b/README.md new file mode 100644 index 0000000..463364e --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +# Qmail + +The qmail utility is a tool designed for simple, fast, and convenient email sending. It allows saving settings for multiple servers in a configuration file and then sending content from a file or standard input with a single simple command. + +## install + +Install go + +```bash +$ make +$ make install +``` + +## Example + +```bash +$ qmail -s myServer ./mail.eml +``` + +or + +```bash +$ cat "text" | qmail -s myServer +``` diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..af2da93 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,119 @@ +package main + +import ( + "fmt" + "os" + "qmail/internal/config" + "qmail/internal/mail" + + "github.com/jessevdk/go-flags" + "github.com/olekukonko/tablewriter" +) + +type appOptions struct { + Config string `short:"c" long:"config" description:"Config file"` + + Server string `short:"s" long:"server" description:"SMTP server for sending"` + PrintList bool `short:"l" long:"list" description:"Print servers"` + + From string `long:"from" description:"e-mail address indicated as sender"` + To []string `long:"to" description:""` +} + +type helpOptions struct { + PrintHelp bool `short:"h" long:"help" description:"Show this help message"` +} + +func main() { + appOpt := appOptions{ + Config: config.DefaultConfigPath, + } + + f := flags.NewParser(&appOpt, flags.PassDoubleDash) + f.Usage = "[OPTIONS] [FILE]" + f.LongDescription = "The qmail utility is a tool designed for simple, fast, and convenient email sending" + + helpOpt := helpOptions{} + + f.AddGroup("Help options", "", &helpOpt) + + args, err := f.ParseArgs(os.Args) + if err != nil { + fmt.Println(err) + return + } + + if helpOpt.PrintHelp { + f.WriteHelp(os.Stderr) + return + } + + cfg, err := config.LoadConfig(appOpt.Config) + if err != nil { + fmt.Println(err) + return + } + + if appOpt.PrintList { + table := tablewriter.NewWriter(os.Stdout) + + table.SetHeader([]string{"Name", "Host", "Port", "TLS", "Login"}) + + for _, s := range cfg.Servers { + login := "" + + if s.Auth != nil { + login = s.Auth.Login + } + + useTLS := "-" + if s.UseTLS { + useTLS = "+" + } + + table.Append([]string{ + s.Name, + s.Host, + fmt.Sprintf("%d", s.Port), + useTLS, + login, + }) + } + + table.Render() + + return + } + + if appOpt.Server != "" { + var server *config.Server + + for _, s := range cfg.Servers { + if s.Name == appOpt.Server { + server = &s + } + } + + if server == nil { + panic("omg") + } + + var bodyFile string + + if len(args) > 1 { + bodyFile = args[1] + } else { + bodyFile = "/dev/stdin" + } + + body, err := os.ReadFile(bodyFile) + if err != nil { + panic("qq") + } + + err = mail.SendMail(server, appOpt.From, appOpt.To, body) + if err != nil { + panic(err) + } + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..fc80a05 --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module qmail + +go 1.24.0 + +require ( + github.com/go-yaml/yaml v2.1.0+incompatible + github.com/jessevdk/go-flags v1.6.1 + github.com/olekukonko/tablewriter v0.0.5 +) + +require ( + github.com/mattn/go-runewidth v0.0.9 // indirect + golang.org/x/sys v0.21.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..17ddd1f --- /dev/null +++ b/go.sum @@ -0,0 +1,20 @@ +github.com/go-yaml/yaml v2.1.0+incompatible h1:RYi2hDdss1u4YE7GwixGzWwVo47T8UQwnTLB6vQiq+o= +github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= +github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4= +github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc= +github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..549bc44 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,54 @@ +package config + +import ( + "errors" + "fmt" + "os" + "path" + "strings" + + "github.com/go-yaml/yaml" +) + +func LoadConfig(filePath string) (*Config, error) { + cfg := Config{} + + isDefaultConfig := filePath == DefaultConfigPath + + if strings.HasPrefix(filePath, "~") { + if v, ok := os.LookupEnv("HOME"); ok { + filePath = strings.Replace(filePath, "~", v, 1) + } else { + return nil, fmt.Errorf("$HOME not set in env") + } + } + + if _, err := os.Stat(filePath); errors.Is(err, os.ErrNotExist) { + if isDefaultConfig { + dir, _ := path.Split(filePath) + err := os.MkdirAll(dir, os.ModePerm) + if err != nil { + return nil, err + } + + err = os.WriteFile(filePath, []byte(DefaultConfig), 0644) + if err != nil { + return nil, err + } + } + + return &cfg, nil + } + + data, err := os.ReadFile(filePath) + if err != nil { + return nil, err + } + + err = yaml.Unmarshal([]byte(data), &cfg) + if err != nil { + return nil, err + } + + return &cfg, nil +} diff --git a/internal/config/const.go b/internal/config/const.go new file mode 100644 index 0000000..9618ec4 --- /dev/null +++ b/internal/config/const.go @@ -0,0 +1,15 @@ +package config + +const DefaultConfigPath = "~/.config/qmail/main.yaml" + +const DefaultConfig = ` +servers: + +# - name: s1 +# host: localhost +# port: 587 +# use-tls: true +# auth: +# login: login +# password: password +` diff --git a/internal/config/struct.go b/internal/config/struct.go new file mode 100644 index 0000000..2839320 --- /dev/null +++ b/internal/config/struct.go @@ -0,0 +1,24 @@ +package config + +type Auth struct { + Login string `yaml:"login"` + Password string `yaml:"password"` +} + +type Server struct { + Name string `yaml:"name"` + + Host string `yaml:"host"` + Port uint16 `yaml:"port"` + + From string `yaml:"from"` + To string `yaml:"to"` + + UseTLS bool `yaml:"use-tls"` + + Auth *Auth `yaml:"auth"` +} + +type Config struct { + Servers []Server +} diff --git a/internal/mail/mail.go b/internal/mail/mail.go new file mode 100644 index 0000000..57d04e6 --- /dev/null +++ b/internal/mail/mail.go @@ -0,0 +1,99 @@ +package mail + +import ( + "crypto/tls" + "fmt" + "net/smtp" + + "qmail/internal/config" +) + +func SendMail(server *config.Server, from string, to []string, data []byte) error { + fmt.Println("Send...") + if server.Host == "" || server.Port == 0 { + return fmt.Errorf("invalide server host or port") + } + + fmt.Printf("Dial:%s:%d\n", server.Host, server.Port) + // Connect to the remote SMTP server. + + c, err := smtp.Dial(fmt.Sprintf("%s:%d", server.Host, server.Port)) + if err != nil { + return err + } + + fmt.Println("omg") + if server.UseTLS { + fmt.Println("start TLS") + err = c.StartTLS(&tls.Config{ + InsecureSkipVerify: true, + }) + if err != nil { + return err + } + } + + if server.Auth != nil { + fmt.Println("Auth") + auth := smtp.PlainAuth("", server.Auth.Login, server.Auth.Password, server.Host) + err = c.Auth(auth) + if err != nil { + return err + } + } + + if from == "" { + from = server.From + } + + if from == "" { + return fmt.Errorf("From not set") + } + + fmt.Printf("Set from: %s\n", from) + // Set the sender and recipient first + if err := c.Mail(from); err != nil { + return err + } + + if len(to) == 0 { + to = []string{server.To} + } + + if len(to) == 0 { + return fmt.Errorf("To not set") + } + + for _, tt := range to { + if err := c.Rcpt(tt); err != nil { + return err + } + } + + // Send the email body. + wc, err := c.Data() + if err != nil { + return err + } + + fmt.Printf("Send data") + _, err = wc.Write(data) + if err != nil { + return err + } + + err = wc.Close() + if err != nil { + return err + } + + fmt.Println("Code connection") + // Send the QUIT command and close the connection. + err = c.Quit() + if err != nil { + return err + } + + fmt.Println("Done") + return nil +}