From 533681e0f83ec4f69b3b8e9f1982ed9f089285b4 Mon Sep 17 00:00:00 2001 From: Clemens Fries Date: Tue, 1 Nov 2016 17:11:58 +0100 Subject: Initial commit --- common/configuration.go | 142 ++++++++++++++++++++++++++ common/configuration_test.go | 105 ++++++++++++++++++++ common/consts.go | 49 +++++++++ common/message.go | 231 +++++++++++++++++++++++++++++++++++++++++++ common/message_test.go | 82 +++++++++++++++ common/util.go | 63 ++++++++++++ common/util_test.go | 79 +++++++++++++++ 7 files changed, 751 insertions(+) create mode 100644 common/configuration.go create mode 100644 common/configuration_test.go create mode 100644 common/consts.go create mode 100644 common/message.go create mode 100644 common/message_test.go create mode 100644 common/util.go create mode 100644 common/util_test.go (limited to 'common') diff --git a/common/configuration.go b/common/configuration.go new file mode 100644 index 0000000..2948116 --- /dev/null +++ b/common/configuration.go @@ -0,0 +1,142 @@ +/* configuration.go: module for managing message properties + * + * Copyright (C) 2016 Clemens Fries + * + * 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 . + */ +package common + +import ( + "fmt" + "github.com/go-ini/ini" + "sort" + "strconv" + "strings" +) + +type Configuration struct { + Data map[string]string +} + +func NewConfiguration() *Configuration { + return &Configuration{ + Data: map[string]string{}, + } +} + +func (c *Configuration) Get(key string) string { + return c.Data[key] +} + +func (c *Configuration) Set(key string, value string) { + c.Data[key] = value +} + +// Load configuration from an array of strings in the form `key: value`. +func (c *Configuration) Load(text []string) { + c.Data = map[string]string{} + + for _, line := range text { + r := strings.SplitN(line, ":", 2) + + key := strings.TrimSpace(r[0]) + + if len(r) == 2 { + c.Data[key] = strings.TrimSpace(r[1]) + } else { + if key != "" { + c.Data[key] = "" + } + } + } +} + +// Merge the `src` configuration into this configuration. +func (c *Configuration) MergeWith(src *Configuration) { + for k, v := range (*src).Data { + (*c).Data[k] = v + } +} + +// Merge arguments from a INI section into the given map. +func mergeIniSection(section *ini.Section, dst *map[string]string) { + for _, k := range section.KeyStrings() { + (*dst)[k] = section.Key(k).String() + } +} + +// Merges the "default" and the "cmd" section from CONF_CONFIG_FILENAME. +func (c *Configuration) MergeWithIni(cmd string) { + cfg, err := ini.Load(c.Get(CONF_CONFIG_FILENAME)) + + if err == nil { + section, err := cfg.GetSection("default") + + if err == nil { + mergeIniSection(section, &c.Data) + } + + section, err = cfg.GetSection(cmd) + + if err == nil { + mergeIniSection(section, &c.Data) + } + } +} + +// Merge arguments from the DocOpt parser into a configuration map. All +// arguments that are not `nil` and start with "--" will be merged. +// Booleans will be converted to strings. +func (c *Configuration) MergeWithDocOptArgs(cmd string, args *map[string]interface{}) { + for k, v := range *args { + + // The args list contains the name of the command, but we are not + // interested in it. + if k == cmd { + continue + } + + // Merge all args that start with -- and where the value is not nil + if v != nil && (len(k) > 2 && k[0:2] == "--") { + switch v.(type) { + case string: + c.Data[k[2:]] = v.(string) + case bool: + c.Data[k[2:]] = strconv.FormatBool(v.(bool)) + } + + } + } +} + +// Dump the configuration as strings in the form `key: value`. +func (c *Configuration) DumpConfig() []string { + keys := make([]string, len(c.Data)) + + i := 0 + for k, _ := range c.Data { + keys[i] = k + i++ + } + + sort.Strings(keys) + + result := make([]string, len(keys)) + + for i, k := range keys { + result[i] = fmt.Sprintf("%s: %s", k, c.Get(k)) + } + + return result +} diff --git a/common/configuration_test.go b/common/configuration_test.go new file mode 100644 index 0000000..dbf2697 --- /dev/null +++ b/common/configuration_test.go @@ -0,0 +1,105 @@ +/* configuration_test.go: unit tests for the configuration module + * + * Copyright (C) 2016 Clemens Fries + * + * 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 . + */ +package common + +import ( + "github.com/go-ini/ini" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestConfiguration_MergeWith(t *testing.T) { + src := Configuration{ + Data: map[string]string{ + "port": "1", + "server": "example.com", + }, + } + + dst := Configuration{ + Data: map[string]string{ + "server": "example.net", + "workdir": "/home/foo", + }, + } + + expected := map[string]string{ + "port": "1", + "server": "example.com", + "workdir": "/home/foo", + } + + dst.MergeWith(&src) + + assert.Equal(t, expected, dst.Data) +} + +func TestConfiguration_MergeWithIni(t *testing.T) { + dst := map[string]string{ + "port": "1", + "server": "example.com", + } + + iniContents := []byte(` + [foo] + port = 1234 + workdir = /home/foo + `) + + expected := map[string]string{ + "port": "1234", + "server": "example.com", + "workdir": "/home/foo", + } + + cfg, _ := ini.Load(iniContents) + + section := cfg.Section("foo") + + mergeIniSection(section, &dst) + + assert.Equal(t, expected, dst) +} + +func TestConfiguration_Load(t *testing.T) { + text := []string{ + "to: me@example.com", + "from: ", + "subject", + "", + } + + expected := map[string]string{ + "to": "me@example.com", + "from": "", + "subject": "", + } + + conf := Configuration{} + conf.Load(text) + + assert.Equal(t, expected, conf.Data) +} + +func TestConfiguration_Get(t *testing.T) { + conf := NewConfiguration() + + conf.Set("foo", "bar") + + assert.Equal(t, "bar", conf.Get("foo")) +} diff --git a/common/consts.go b/common/consts.go new file mode 100644 index 0000000..822f413 --- /dev/null +++ b/common/consts.go @@ -0,0 +1,49 @@ +/* consts.go: global constants + * + * Copyright (C) 2016 Clemens Fries + * + * 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 . + */ +package common + +// Project wide constants. +const ( + TIME_FORMAT = "15:04" + DATE_FORMAT = "2006-01-02" + DATETIME_FORMAT = DATE_FORMAT + " " + TIME_FORMAT + + DIR_TODO = "todo" + DIR_DRAFTS = "drafts" + DIR_ERRORS = "errors" + DIR_DONE = "done" + + CMD_USAGE = "usage" + CMD_RUN = "run" + + CONF_WORKDIR = "workdir" + CONF_DATE = "date" + CONF_DAYS = "days" + CONF_SUBJECT = "subject" + CONF_TO = "to" + CONF_FROM = "from" + CONF_REPLY_TO = "reply-to" + CONF_CC = "cc" + CONF_BCC = "bcc" + CONF_CONFIG_FILENAME = "config" + CONF_SMTP_SERVER = "server" + CONF_SMTP_PORT = "port" + CONF_SMTP_INSECURE = "insecure" + CONF_NOT_BEFORE = "not-before" + CONF_NOT_AFTER = "not-after" +) diff --git a/common/message.go b/common/message.go new file mode 100644 index 0000000..fbea53d --- /dev/null +++ b/common/message.go @@ -0,0 +1,231 @@ +/* message.go: module for managing message information + * + * Copyright (C) 2016 Clemens Fries + * + * 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 . + */ +package common + +import ( + "bufio" + "fmt" + "net/mail" + "os" + "path/filepath" +) + +type Message struct { + Conf Configuration + Body []string + Name string +} + +// Supporting sort.Interface. +type Messages []Message + +// Load all messages in the given directory. +func NewMessagesFromDirectory(dir string) Messages { + messages := []Message{} + + err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Only interested in regular files + if !info.Mode().IsRegular() { + return nil + } + + // Parse only .msg files + if filepath.Ext(info.Name()) != ".msg" { + return nil + } + + message, err := NewMessageFromFile(path) + + if err != nil { + return err + } + + messages = append(messages, message) + + return nil + }) + + // TODO: Maybe return error? + if err != nil { + fmt.Printf("Error while reading messages from %s:\n\t%s\n", dir, err.Error()) + } + + return messages +} + +func (m Messages) Len() int { return len(m) } +func (m Messages) Swap(i, j int) { m[i], m[j] = m[j], m[i] } +func (m Messages) Less(i, j int) bool { + // TODO: We are being very confident here w.r.t. errors + t1, _ := ParseTime(m[i].Get(CONF_DATE)) + t2, _ := ParseTime(m[j].Get(CONF_DATE)) + + return t1.Before(t2) +} + +// Create a new, empty Message. +func NewMessage() *Message { + return &Message{ + Conf: *NewConfiguration(), + Body: []string{}, + Name: "", + } +} + +// Construct new Message from the given file. +func NewMessageFromFile(path string) (Message, error) { + f, err := os.Open(path) + + if err != nil { + return Message{}, err + } + + defer f.Close() + + scanner := bufio.NewScanner(f) + + lines := []string{} + + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + + msgConf, msgBody := SplitMessage(lines) + + configuration := Configuration{} + configuration.Load(msgConf) + + return Message{ + Body: msgBody, + Conf: configuration, + Name: filepath.Base(path), + }, nil +} + +// Write a message to a file, such that it could be loaded again. +func (m *Message) WriteToFile(file string) error { + f, err := os.OpenFile(file, os.O_CREATE|os.O_WRONLY, 0666) + + if err != nil { + return err + } + + defer f.Close() + + for _, s := range m.Conf.DumpConfig() { + f.WriteString(s) + f.WriteString("\n") + } + + f.WriteString("\n") + + for _, s := range m.Body { + f.WriteString(s) + f.WriteString("\n") + } + + return nil +} + +// Return the specified configuration key's value. +func (m *Message) Get(key string) string { + return m.Conf.Data[key] +} + +// Checks if set and if ParseAddressList succeeds +func verifyAddressList(addresses string) error { + if addresses == "" { + // TODO: Why is this part different than the one in verifyAddress()? Maybe not intentional? + return nil + } + + _, err := mail.ParseAddressList(addresses) + + if err != nil { + return err + } + + return nil +} + +// Checks if no nil and if ParseAddress succeeds. +func verifyAddress(paramName string, address string) error { + if address == "" { + return fmt.Errorf("'%s' parameter is missing", paramName) + } + + _, err := mail.ParseAddress(address) + + if err != nil { + return err + } + + return nil +} + +// Verify if a message has all necessary parameters. We need at least to, from, +// subject, and date. This will also verify optional address lists, etc. +func (m *Message) Verify() []error { + errors := []error{} + + if err := verifyAddress("from", m.Get("from")); err != nil { + errors = append(errors, err) + } + + if err := verifyAddress("to", m.Get("to")); err != nil { + errors = append(errors, err) + } + + if m.Get("subject") == "" { + errors = append(errors, fmt.Errorf("'subject' parameter is missing")) + } + + if m.Get("date") == "" { + errors = append(errors, fmt.Errorf("'date' parameter is missing")) + } else { + _, err := ParseTime(m.Get("date")) + + if err != nil { + errors = append(errors, fmt.Errorf("'date' format error: %s", err.Error())) + } + } + + if m.Get("reply-to") != "" { + if err := verifyAddress("nil", m.Get("to")); err != nil { + errors = append(errors, err) + } + } + + if err := verifyAddressList(m.Get("cc")); err != nil { + errors = append(errors, err) + } + + if err := verifyAddressList(m.Get("bcc")); err != nil { + errors = append(errors, err) + } + + if len(errors) == 0 { + return nil + } + + return errors +} diff --git a/common/message_test.go b/common/message_test.go new file mode 100644 index 0000000..74ed09f --- /dev/null +++ b/common/message_test.go @@ -0,0 +1,82 @@ +/* message_test.go: unit tests for the message module + * + * Copyright (C) 2016 Clemens Fries + * + * 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 . + */ +package common + +import ( + "github.com/stretchr/testify/assert" + "path/filepath" + "sort" + "testing" +) + +func TestReadMessagesAndVerify(t *testing.T) { + workdir := "testdata" + + conf := NewConfiguration() + conf.Set("from", "me@example.com") + + messages := NewMessagesFromDirectory(filepath.Join(workdir, "todo")) + + for _, message := range messages { + // If we don't to this, it will fail because "from" is missing + message.Conf.MergeWith(conf) + + if err := message.Verify(); err != nil { + t.Errorf("Verification of message '%s' failed with %s", message.Name, err) + } + } +} + +func TestMessage_Get(t *testing.T) { + message := Message{Conf: *NewConfiguration()} + + message.Conf.Set("foo", "bar") + + assert.Equal(t, "bar", message.Get("foo")) +} + +func TestMessagesSort(t *testing.T) { + messages := []Message{ + { + Name: "1", + Conf: Configuration{ + Data: map[string]string{ + "date": "2000-01-01", + }}}, + { + Name: "2", + Conf: Configuration{ + Data: map[string]string{ + "date": "2010-01-01", + }}}, + { + Name: "3", + Conf: Configuration{ + Data: map[string]string{ + "date": "2005-01-01", + }}}, + } + + sort.Sort(Messages(messages)) + + for i, k := range []string{"1", "3", "2"} { + if messages[i].Name != k { + t.Errorf("expected: %s, but was %s", k, messages[i].Name) + } + } +} diff --git a/common/util.go b/common/util.go new file mode 100644 index 0000000..a4422c7 --- /dev/null +++ b/common/util.go @@ -0,0 +1,63 @@ +/* util.go: various utility functions + * + * Copyright (C) 2016 Clemens Fries + * + * 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 . + */ +package common + +import ( + "strings" + "time" +) + +// Split a text in two parts on the first blank line. +func SplitMessage(text []string) ([]string, []string) { + conf := []string{} + body := []string{} + + inBody := false + + for _, line := range text { + if !inBody && strings.TrimSpace(line) == "" { + inBody = true + continue // skip the separating line + } + + if inBody { + body = append(body, line) + } else { + conf = append(conf, line) + } + } + + return conf, body +} + +// Parse a time which may either be just a date or a date with time. +func ParseTime(datetime string) (time.Time, error) { + result, err := time.ParseInLocation(DATE_FORMAT, datetime, time.Now().Location()) + + if err == nil { + return result, nil + } + + result, err = time.ParseInLocation(DATETIME_FORMAT, datetime, time.Now().Location()) + + if err == nil { + return result, nil + } + + return result, err +} diff --git a/common/util_test.go b/common/util_test.go new file mode 100644 index 0000000..201cfe0 --- /dev/null +++ b/common/util_test.go @@ -0,0 +1,79 @@ +/* util_test.go: unit tests for utility functions + * + * Copyright (C) 2016 Clemens Fries + * + * 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 . + */ +package common + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestSplitMessage(t *testing.T) { + message := []string{ + "to: me@example.com", + "subject: foo", + "", + "Dear Me,", + "", + "have a nice day.", + } + + conf, body := SplitMessage(message) + + expectedConf := []string{ + "to: me@example.com", + "subject: foo", + } + + expectedBody := []string{ + "Dear Me,", + "", + "have a nice day.", + } + + assert.Equal(t, expectedConf, conf) + assert.Equal(t, expectedBody, body) +} + +func TestParseTime(t *testing.T) { + d1 := "2061-07-28" + d2 := "2061-07-28 12:23" + invalid1 := "2061/07/28" + invalid2 := "2061-07-28 12:23:00" + + r, err := ParseTime(d1) + assert.Nil(t, err) + + if r.Hour() != 0 || r.Minute() != 0 { + t.Errorf("expected Hour and Minute to be 0, but was %s", r) + } + + r, err = ParseTime(d2) + assert.Nil(t, err) + + if r.Hour() != 12 || r.Minute() != 23 { + t.Errorf("expected Hour and Minute to be 12:23, but was %s", r) + } + + // Expected to fail + _, err = ParseTime(invalid1) + assert.NotNil(t, err) + + // Expected to fail + _, err = ParseTime(invalid2) + assert.NotNil(t, err) +} -- cgit