aboutsummaryrefslogtreecommitdiffstats
path: root/cmd
diff options
context:
space:
mode:
authorClemens Fries <github-clockrotz@xenoworld.de>2016-11-01 17:11:58 +0100
committerClemens Fries <github-clockrotz@xenoworld.de>2016-11-01 17:11:58 +0100
commit533681e0f83ec4f69b3b8e9f1982ed9f089285b4 (patch)
tree4792c3b3e6b353798f8564ff90d6572081755e1f /cmd
Initial commit
Diffstat (limited to 'cmd')
-rw-r--r--cmd/check.go131
-rw-r--r--cmd/create.go212
-rw-r--r--cmd/create_test.go41
-rw-r--r--cmd/debug.go73
-rw-r--r--cmd/next.go91
-rw-r--r--cmd/run.go316
-rw-r--r--cmd/run_test.go39
7 files changed, 903 insertions, 0 deletions
diff --git a/cmd/check.go b/cmd/check.go
new file mode 100644
index 0000000..2b7316e
--- /dev/null
+++ b/cmd/check.go
@@ -0,0 +1,131 @@
+/* check.go: check a given message for problems
+ *
+ * Copyright (C) 2016 Clemens Fries <github-clockrotz@xenoworld.de>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package cmd
+
+import (
+ "fmt"
+ "github.com/docopt/docopt.go"
+ . "github.com/githubert/clockrotz/common"
+ "os"
+ "path/filepath"
+ "sort"
+)
+
+var usageCheck =
+// tag::check[]
+`
+Usage:
+ clockrotz check [--silent] [FILE]
+
+Options:
+ --silent Suppress output, useful for silent checks.
+ FILE Check only the given file.
+
+If no FILE is provided, check will inspect all messages in the todo/ and the
+drafts/ folder. The command exit with a code of 0, if there were no problems.
+` // end::check[]
+
+func Check(argv []string, conf *Configuration) {
+ args, _ := docopt.Parse(usageCheck, argv, true, "", false)
+
+ silent := args["--silent"].(bool)
+
+ ok := true
+
+ if args["FILE"] != nil {
+ message, err := NewMessageFromFile(args["FILE"].(string))
+
+ if err != nil {
+ fmt.Printf("Error while reading message: %s\n", err.Error())
+ os.Exit(1)
+ }
+
+ message.Conf.MergeWith(conf)
+ ok = checkMessage(message, silent)
+ } else {
+ draftOk := checkFolder(DIR_DRAFTS, conf, silent)
+
+ if !silent {
+ // A bit of space between the drafts/ and todo/ listing
+ fmt.Println()
+ }
+
+ todoOk := checkFolder(DIR_TODO, conf, silent)
+
+ ok = draftOk && todoOk
+ }
+
+ if ok {
+ os.Exit(0)
+ } else {
+ os.Exit(1)
+ }
+}
+
+// Inspect all messages in a folder.
+func checkFolder(folder string, conf *Configuration, silent bool) bool {
+ messages := NewMessagesFromDirectory(filepath.Join(conf.Get(CONF_WORKDIR), folder))
+ sort.Sort(Messages(messages))
+
+ if !silent {
+ fmt.Printf("in %s:\n", folder)
+ }
+
+ ok := true
+ count := 0
+
+ for _, message := range messages {
+ count++
+ message.Conf.MergeWith(conf)
+
+ if !checkMessage(message, silent) {
+ ok = false
+ }
+ }
+
+ if ok && !silent {
+ fmt.Printf(" All (%d) messages are valid.\n", count)
+ }
+
+ return ok
+}
+
+// Check the given message. If `silent` is false, all problems will be printed
+// to stdout.
+func checkMessage(message Message, silent bool) bool {
+ ok := true
+ errs := message.Verify()
+
+ if errs != nil {
+ ok = false
+ }
+
+ if silent {
+ return ok
+ }
+
+ if !ok {
+ fmt.Printf(" %s:\n", message.Name)
+
+ for _, err := range errs {
+ fmt.Printf(" %s\n", err.Error())
+ }
+ }
+
+ return ok
+}
diff --git a/cmd/create.go b/cmd/create.go
new file mode 100644
index 0000000..3d02bc7
--- /dev/null
+++ b/cmd/create.go
@@ -0,0 +1,212 @@
+/* create.go: help creating a new message
+ *
+ * Copyright (C) 2016 Clemens Fries <github-clockrotz@xenoworld.de>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package cmd
+
+import (
+ "bufio"
+ "fmt"
+ "github.com/docopt/docopt.go"
+ . "github.com/githubert/clockrotz/common"
+ "io/ioutil"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+var usageCreate =
+// tag::create[]
+`
+Usage:
+ clockrotz create [--draft=FILE] [options]
+
+Options:
+ --help Show this help.
+ --to=ADDR Destination address.
+ --from=ADDR Sender address.
+ --subject=ADDR Short subject.
+ --cc=ADDR Set "Cc".
+ --bcc=ADDR Set "Bcc".
+ --reply-to=ADDR Set "Reply-To".
+ --draft=FILE Use FILE from the drafts/ folder as template.
+` // end::create[]
+
+func Create(argv []string, conf *Configuration) {
+ args, _ := docopt.Parse(usageCreate, argv, true, "", false)
+
+ tmpFile, err := ioutil.TempFile("", "clockrotz")
+
+ if err != nil {
+ fmt.Printf("Error while creating temporary file: %s\n", err.Error())
+ os.Exit(1)
+ }
+
+ // We close the file right away, because we need only its soulless
+ // shell, mwhaha.
+ tmpFile.Close()
+
+ draftsDir := filepath.Join(conf.Get(CONF_WORKDIR), DIR_DRAFTS)
+ todoDir := filepath.Join(conf.Get(CONF_WORKDIR), DIR_TODO)
+
+ defer os.Remove(tmpFile.Name())
+
+ message := NewMessage()
+
+ if args["--draft"] != nil {
+ draft := filepath.Join(draftsDir, args["--draft"].(string))
+ m, err := NewMessageFromFile(draft)
+
+ if err != nil {
+ fmt.Printf("Error while reading draft: %s\n", err.Error())
+ os.Exit(1)
+ }
+
+ message = &m
+ }
+
+ message.Conf.MergeWithDocOptArgs(CMD_USAGE, &args)
+
+ // MergeWithDocOptArgs will also copy --draft and --help over, but we do not want
+ // that.
+ delete(message.Conf.Data, "draft")
+ delete(message.Conf.Data, "help")
+
+ if message.Get("date") == "" {
+ // Add tomorrow's date.
+ message.Conf.Set("date", time.Now().AddDate(0, 0, 1).Format(DATE_FORMAT))
+ }
+
+ if message.Get("subject") == "" {
+ message.Conf.Set("subject", "Type subject here")
+ }
+
+ if len(message.Body) == 0 {
+ message.Body = append(message.Body, "Add message to the world of tomorrow here.")
+ }
+
+ message.WriteToFile(tmpFile.Name())
+
+ editor := editor()
+
+ fmt.Printf("Opening %s using %s.\n", tmpFile.Name(), editor)
+
+ cmd := exec.Command(editor, tmpFile.Name())
+ cmd.Stdin = os.Stdin
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ cmd.Run()
+
+ if cmd.ProcessState.Success() {
+ fmt.Printf("\nSave message? ([(y)es], (d)raft, (n)o): ")
+ reader := bufio.NewReader(os.Stdin)
+ response, err := reader.ReadString('\n')
+
+ if err != nil {
+ fmt.Printf("Error when reading response: %s\n", err.Error())
+ os.Exit(1)
+ }
+
+ var dst string
+
+ saved := false
+
+ response = strings.TrimSpace(response)
+
+ switch response {
+ case "y", "yes", "":
+ dst = filepath.Join(todoDir, filepath.Base(tmpFile.Name())+".msg")
+ dst, err = copyFile(tmpFile.Name(), dst, false)
+ saved = true
+ case "d", "draft":
+ dst = filepath.Join(draftsDir, filepath.Base(tmpFile.Name())+".msg")
+ dst, err = copyFile(tmpFile.Name(), dst, false)
+ saved = true
+ }
+
+ if err != nil {
+ fmt.Printf("Error when saving %s: %s\n", dst, err)
+ } else if saved {
+ fmt.Printf("Saved as: %s\n", dst)
+ } else {
+ fmt.Println("Discarding message.")
+ }
+ }
+}
+
+// Copy file from `src` to `dst`. If `overwrite` is false, then an alternative
+// file name will be used and returned as string.
+// TODO: Portability issues (cp) / https://github.com/golang/go/issues/8868
+func copyFile(src, dst string, overwrite bool) (string, error) {
+ if !overwrite {
+ var err error
+
+ dst, err = nextFreeFilename(dst)
+
+ if err != nil {
+ return "", err
+ }
+ }
+
+ err := exec.Command("cp", "-f", src, dst).Run()
+
+ return dst, err
+}
+
+func nextFreeFilename(dst string) (string, error) {
+ _, e := os.Stat(dst)
+
+ // If the file does not exist, we can use the name
+ if os.IsNotExist(e) {
+ return dst, nil
+ }
+
+ alt := ""
+
+ for i := 0; i < 255; i++ {
+ name := fmt.Sprintf("%s.%d", dst, i)
+ _, e := os.Stat(name)
+
+ if os.IsNotExist(e) {
+ alt = name
+ break
+ }
+ }
+
+ if alt == "" {
+ return "", fmt.Errorf("No suitable file name could be found.")
+ }
+
+ return alt, nil
+}
+
+func editor() string {
+ editor := os.Getenv("VISUAL")
+
+ if editor != "" {
+ return editor
+ }
+
+ editor = os.Getenv("EDITOR")
+
+ if editor != "" {
+ return editor
+ }
+
+ return "vi"
+}
diff --git a/cmd/create_test.go b/cmd/create_test.go
new file mode 100644
index 0000000..388ce36
--- /dev/null
+++ b/cmd/create_test.go
@@ -0,0 +1,41 @@
+/* create_test.go: unit tests for 'create' command
+ *
+ * Copyright (C) 2016 Clemens Fries <github-clockrotz@xenoworld.de>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package cmd
+
+import (
+ "fmt"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "io/ioutil"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestEditor(t *testing.T) {
+ os.Setenv("VISUAL", "")
+ os.Setenv("EDITOR", "")
+
+ assert.Equal(t, "vi", editor())
+
+ os.Setenv("EDITOR", "editor")
+ assert.Equal(t, "editor", editor())
+
+ os.Setenv("VISUAL", "visual")
+ assert.Equal(t, "visual", editor())
+} \ No newline at end of file
diff --git a/cmd/debug.go b/cmd/debug.go
new file mode 100644
index 0000000..03f529b
--- /dev/null
+++ b/cmd/debug.go
@@ -0,0 +1,73 @@
+/* debug.go: show debug information for a provided message
+ *
+ * Copyright (C) 2016 Clemens Fries <github-clockrotz@xenoworld.de>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package cmd
+
+import (
+ "fmt"
+ "github.com/docopt/docopt.go"
+ . "github.com/githubert/clockrotz/common"
+ "os"
+)
+
+var usageDebug =
+// tag::debug[]
+`
+Usage:
+ clockrotz debug FILENAME
+
+Options:
+ --help Show this help.
+ FILENAME File name of the message to inspect.
+` // end::debug[]
+
+func Debug(argv []string, conf *Configuration) {
+ args, _ := docopt.Parse(usageDebug, argv, true, "", false)
+
+ message, err := NewMessageFromFile(args["FILENAME"].(string))
+
+ if err != nil {
+ fmt.Printf("Error while reading file: %s\n", err.Error())
+ os.Exit(1)
+ }
+
+ fmt.Println("Configuration\n-------------")
+
+ message.Conf.MergeWith(conf)
+
+ for _, line := range message.Conf.DumpConfig() {
+ fmt.Println(line)
+ }
+
+ // TODO: Verify Message?
+ e, err := prepareEmail(&message)
+
+ if err != nil {
+ fmt.Println(err.Error())
+ os.Exit(1)
+ }
+
+ m, err := e.Bytes()
+
+ if err != nil {
+ fmt.Println(err.Error())
+ os.Exit(1)
+ }
+
+ fmt.Println("\nEmail message\n-------------")
+ fmt.Println(string(m))
+}
diff --git a/cmd/next.go b/cmd/next.go
new file mode 100644
index 0000000..f62aad2
--- /dev/null
+++ b/cmd/next.go
@@ -0,0 +1,91 @@
+/* next.go: show upcoming messages
+ *
+ * Copyright (C) 2016 Clemens Fries <github-clockrotz@xenoworld.de>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package cmd
+
+import (
+ "fmt"
+ "github.com/docopt/docopt.go"
+ . "github.com/githubert/clockrotz/common"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "time"
+)
+
+var usageNext =
+// tag::next[]
+`
+Usage:
+ clockrotz next [--days=DAYS | --all]
+
+Options:
+ --help Show this help.
+ --days=DAYS List messages for the next DAYS days. (default: 7)
+ --all List all pending messages.
+` // end::next[]
+
+func Next(argv []string, conf *Configuration) {
+ args, _ := docopt.Parse(usageNext, argv, true, "", false)
+
+ if args["--days"] != nil {
+ conf.Set("days", args["--days"].(string))
+ }
+
+ days, err := strconv.ParseInt(conf.Get(CONF_DAYS), 10, 0)
+
+ if err != nil {
+ fmt.Println("Error while parsing value of --days")
+ return
+ }
+
+ all := args["--all"].(bool)
+
+ future := buildTime(time.Now().AddDate(0, 0, int(days)), 23, 59, false)
+
+ messages := NewMessagesFromDirectory(filepath.Join(conf.Get(CONF_WORKDIR), DIR_TODO))
+ sort.Sort(messages)
+
+ if all {
+ fmt.Printf("Showing all messages.\n\n")
+ } else {
+ fmt.Printf("Showing messages before %s.\n\n", future.Format(DATETIME_FORMAT))
+ }
+
+ count := 0
+
+ for _, message := range messages {
+ message.Conf.MergeWith(conf)
+
+ if errs := message.Verify(); errs != nil {
+ fmt.Printf("Error in message \"%s\". Please run 'clockrotz check'.\n", message.Name)
+ continue
+ }
+
+ // Errors are caught already by Verify()
+ messageDate, _ := ParseTime(message.Get(CONF_DATE))
+
+ if all || messageDate.Before(future) {
+ count++
+ fmt.Printf("%s %s (%s)\n", messageDate.Format(DATETIME_FORMAT), message.Get(CONF_SUBJECT), message.Name)
+ }
+ }
+
+ if count == 0 {
+ fmt.Println("No messages.")
+ }
+}
diff --git a/cmd/run.go b/cmd/run.go
new file mode 100644
index 0000000..4ba7b16
--- /dev/null
+++ b/cmd/run.go
@@ -0,0 +1,316 @@
+/* run.go: send pending messages
+ *
+ * Copyright (C) 2016 Clemens Fries <github-clockrotz@xenoworld.de>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package cmd
+
+import (
+ "crypto/tls"
+ "fmt"
+ "github.com/docopt/docopt.go"
+ . "github.com/githubert/clockrotz/common"
+ "github.com/jordan-wright/email"
+ "net/mail"
+ "net/textproto"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+var usageRun =
+// tag::run[]
+`
+Usage:
+ clockrotz run [options]
+
+Options:
+ --help Show this help.
+ --to=ADDR Destination address.
+ --from=ADDR Sender address.
+ --subject=ADDR Short subject.
+ --cc=ADDR Set "Cc".
+ --bcc=ADDR Set "Bcc".
+ --reply-to=ADDR Set "Reply-To".
+ --not-before=TIME Not before TIME. (default: 00:00)
+ --not-after=TIME Not after TIME. (default: 23:59)
+ --server=HOST SMTP hostname. (default: localhost)
+ --port=PORT SMTP port. (default: 587)
+ --verbose Report on successfully sent messages.
+ --dry-run Do not send the message.
+ --insecure Accept any TLS certificate.
+` // end::run[]
+
+func Run(argv []string, conf *Configuration) {
+ args, _ := docopt.Parse(usageRun, argv, true, "", false)
+ conf.MergeWithDocOptArgs(CMD_RUN, &args)
+
+ verbose := args["--verbose"].(bool)
+ dryRun := args["--dry-run"].(bool)
+ insecure := args["--insecure"].(bool)
+
+ now := time.Now()
+
+ notBeforeTime, err := time.Parse(TIME_FORMAT, conf.Get(CONF_NOT_BEFORE))
+
+ if err != nil {
+ fmt.Printf("Failed parsing not-before time: %s\n", err.Error())
+ return
+ }
+
+ notAfterTime, err := time.Parse(TIME_FORMAT, conf.Get(CONF_NOT_AFTER))
+
+ if err != nil {
+ fmt.Printf("Failed parsing not-after time: %s\n", err.Error())
+ return
+ }
+
+ notBefore := buildTime(now, notBeforeTime.Hour(), notBeforeTime.Minute(), true)
+ notAfter := buildTime(now, notAfterTime.Hour(), notAfterTime.Minute(), false)
+
+ // Return if we are in some quiet period.
+ if now.After(notAfter) || now.Before(notBefore) {
+ return
+ }
+
+ messages := NewMessagesFromDirectory(filepath.Join(conf.Get(CONF_WORKDIR), DIR_TODO))
+
+ verificationError := false
+
+ for _, message := range messages {
+ message.Conf.MergeWith(conf)
+ err := processMessage(message, now, dryRun, insecure, verbose)
+
+ if err != nil {
+ verificationError = true
+ continue
+ }
+ }
+
+ if verificationError {
+ // FIXME: This suggests that other messages were not sent, but they were....
+ fmt.Println("There were errors when verifying one or more messages.")
+ fmt.Println("Please run 'clockrotz check'")
+ os.Exit(1)
+ }
+}
+
+func processMessage(message Message, now time.Time, dryRun, insecure, verbose bool) error {
+ if errs := message.Verify(); errs != nil {
+ return fmt.Errorf("Message %s failed verification.", message.Name)
+ }
+
+ date, err := ParseTime(message.Get("date"))
+
+ if err != nil {
+ return err
+ }
+
+ if !now.After(date) {
+ return nil
+ }
+
+ sendErr := sendMessage(message, dryRun, insecure)
+
+ if sendErr != nil {
+ if !dryRun {
+ err := moveMessage(message, DIR_ERRORS)
+
+ if err != nil {
+ fmt.Printf("Error when moving message %s: %s\n", message.Name, err.Error())
+ }
+
+ logMessage(message, DIR_ERRORS, sendErr.Error())
+ }
+
+ fmt.Printf("Error when sending message %s: %s\n", message.Name, sendErr.Error())
+ } else {
+ if !dryRun {
+ err := moveMessage(message, DIR_DONE)
+
+ if err != nil {
+ fmt.Printf("Error when moving message %s: %s\n", message.Name, err.Error())
+ }
+
+ logMessage(message, DIR_DONE, "Successfully delivered.")
+ }
+
+ if verbose {
+ fmt.Printf("Message %s delivered.\n", message.Name)
+ }
+ }
+
+ return nil // TODO: what about errors that are not verification errors?
+}
+
+func logMessage(message Message, dir string, logMessage string) {
+ dstDir := filepath.Join(message.Get(CONF_WORKDIR), dir)
+ filename := filepath.Join(dstDir, message.Name[:len(message.Name)-len(".msg")]+".log")
+
+ f, err := os.Create(filename)
+
+ if err != nil {
+ fmt.Printf("Error when creating log file: %s\n", err.Error())
+ return
+ }
+
+ defer f.Close()
+
+ f.WriteString("Log message:\n")
+ f.WriteString(fmt.Sprintf(" %s\n", logMessage))
+ f.WriteString("\n")
+
+ f.WriteString("\nConfiguration:\n")
+ for _, s := range message.Conf.DumpConfig() {
+ f.WriteString(fmt.Sprintf(" %s\n", s))
+ }
+
+ f.WriteString("\nBody:\n")
+ for _, s := range message.Body {
+ f.WriteString(fmt.Sprintf(" %s\n", s))
+ }
+}
+
+// Turn the given plain address list string into an array.
+func getAddresses(addressList string) ([]string, error) {
+ addresses, err := mail.ParseAddressList(addressList)
+
+ if err != nil {
+ return nil, err
+ }
+
+ result := []string{}
+
+ for _, address := range addresses {
+ result = append(result, address.String())
+ }
+
+ return result, nil
+}
+
+// Prepare a ready-to-send Email message.
+func prepareEmail(message *Message) (*email.Email, error) {
+ e := email.NewEmail()
+ e.From = message.Get(CONF_FROM)
+ e.Subject = message.Get(CONF_SUBJECT)
+
+ // Build list of To addresses.
+ to, err := getAddresses(message.Get(CONF_TO))
+
+ if err != nil {
+ return nil, err
+ }
+
+ e.To = to
+
+ // Set optional Reply-To header.
+ if r := message.Get(CONF_REPLY_TO); r != "" {
+ replyTo, err := getAddresses(r)
+
+ if err != nil {
+ return nil, err
+ }
+
+ e.Headers = textproto.MIMEHeader{"Reply-To": replyTo}
+ }
+
+ // Build list of Cc addresses.
+ if r := message.Get(CONF_CC); r != "" {
+ cc, err := getAddresses(r)
+
+ if err != nil {
+ return nil, err
+ }
+
+ e.Cc = cc
+ }
+
+ // Build list of Bcc addresses.
+ if r := message.Get(CONF_BCC); r != "" {
+ bcc, err := getAddresses(r)
+
+ if err != nil {
+ return nil, err
+ }
+
+ e.Bcc = bcc
+ }
+
+ e.Text = []byte(strings.Join(message.Body, "\n"))
+
+ return e, nil
+}
+
+// Send the given message, unless `dryRun` is true. Use `insecure` to work
+// around things like self-signed certificates.
+func sendMessage(message Message, dryRun bool, insecure bool) error {
+ e, err := prepareEmail(&message)
+
+ if err != nil {
+ return err
+ }
+
+ smtpServer := message.Get(CONF_SMTP_SERVER) + ":" + message.Get(CONF_SMTP_PORT)
+
+ if dryRun {
+ fmt.Printf("Skip sending message %s through %s.\n", message.Name, smtpServer)
+ return nil
+ }
+
+ if insecure {
+ return e.SendWithTLS(smtpServer, nil, &tls.Config{InsecureSkipVerify: true})
+ } else {
+ return e.Send(smtpServer, nil)
+ }
+}
+
+// Move the given message to a folder relative to the working directory.
+func moveMessage(message Message, relative string) error {
+ todo := filepath.Join(message.Get(CONF_WORKDIR), DIR_TODO)
+ to := filepath.Join(message.Get(CONF_WORKDIR), relative)
+
+ // FIXME: BUG: This will overwrite existing messages. Look at create.go:nextFreeFilename()
+ // for ideas on how to resolve this. We could try to use a similar approach.
+ // `foo.msg` to `foo.1.msg` and `foo.1.log`.
+
+ return os.Rename(filepath.Join(todo, message.Name), filepath.Join(to, message.Name))
+}
+
+// Build a time.Time from some given base time. If floor is true, seconds will
+// be set to 0, if false, 59.
+// TODO: Maybe there is a better way?…
+func buildTime(base time.Time, hour int, minute int, floor bool) time.Time {
+ seconds := 0
+
+ if !floor {
+ // We ignore the possibility of leap seconds here, this is
+ // just done so that we can get 23:59:59 instead of 23:59:00.
+ // 23:59:00 would make us miss a whole minute. It would be
+ // nice if there were a way to indicate to time.Date() to
+ // build a date with start of the day / end of the day...
+ seconds = 59
+ }
+
+ return time.Date(
+ base.Year(),
+ base.Month(),
+ base.Day(),
+ hour,
+ minute,
+ seconds,
+ 0,
+ base.Location())
+}
diff --git a/cmd/run_test.go b/cmd/run_test.go
new file mode 100644
index 0000000..2c405e9
--- /dev/null
+++ b/cmd/run_test.go
@@ -0,0 +1,39 @@
+/* run_test.go: unit tests for 'run' command
+ *
+ * Copyright (C) 2016 Clemens Fries <github-clockrotz@xenoworld.de>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package cmd
+
+import (
+ "github.com/stretchr/testify/assert"
+ "testing"
+ "time"
+)
+
+func TestBuildTime(t *testing.T) {
+ h := 8
+ m := 30
+
+ t1 := buildTime(time.Now(), h, m, true)
+
+ assert.Equal(t, t1.Hour(), 8)
+ assert.Equal(t, t1.Minute(), 30)
+ assert.Equal(t, t1.Second(), 0)
+
+ t2 := buildTime(time.Now(), h, m, false)
+
+ assert.Equal(t, t2.Second(), 59)
+}