diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3f75fb3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*.out +*.[68] +_obj +_test +_testmain.go diff --git a/COPYING b/COPYING new file mode 100644 index 0000000..babcc90 --- /dev/null +++ b/COPYING @@ -0,0 +1,15 @@ +Copyright (c) 2010, Simon Lipp + +Permission to use, copy, modify, and distribute this software for +any purpose with or without fee is hereby granted, provided that +the above copyright notice and this permission notice appear in all +copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE +AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL +DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA +OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f92d046 --- /dev/null +++ b/Makefile @@ -0,0 +1,7 @@ +include $(GOROOT)/src/Make.inc + +TARG=maildir +GOFILES=\ + maildir.go\ + +include $(GOROOT)/src/Make.pkg diff --git a/README.md b/README.md new file mode 100644 index 0000000..d3a4404 --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +# PACKAGE + +package maildir + +This package is used for writing mails to a maildir, according to +the specification located at http://www.courier-mta.org/maildir.html + + +# TYPES + + type Maildir struct { + // contains unexported fields + } +Represent a folder in a maildir. The root folder is usually the Inbox. + +`func New(path string, create bool) (m *Maildir, err os.Error)` + +Open a maildir. If create is true and the maildir does not exist, create it. + +`func (m *Maildir) Child(name string, create bool) (*Maildir, os.Error)` + +Get a subfolder of the current folder. If create is true and the folder does not +exist, create it. + +`func (m *Maildir) CreateMail(data io.Reader) (filename string, err os.Error)` +Write a mail to the maildir folder. The data is not encoded or compressed in any way. diff --git a/maildir.go b/maildir.go new file mode 100644 index 0000000..3144da6 --- /dev/null +++ b/maildir.go @@ -0,0 +1,201 @@ +// Copyright 2010 Simon Lipp. +// Distributed under a BSD-like license. See COPYING for more +// details + +// This package is used for writing mails to a maildir, according to +// the specification located at http://www.courier-mta.org/maildir.html +package maildir + +import ( + "encoding/base64" + "bytes" + "strings" + "sync" + "os" + "io" + "fmt" + "time" + "utf16" + paths "path" +) + +var maildirBase64 = base64.NewEncoding("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+,") +var counter chan uint +var counterInit sync.Once + +// Represent a folder in a maildir. The root folder is usually the Inbox. +type Maildir struct { + // The root path ends with a /, others don't, so we can have + // the child of a maildir just with path + "." + encodedChildName + path string +} + +func newWithRawPath(path string, create bool) (m *Maildir, err os.Error) { + // start counter if needed, preventing race condition + counterInit.Do(func() { + counter = make(chan uint) + go (func() { + for i := uint(0); true; i++ { + counter <- i + } + })() + }) + + // Create if needed + _, err = os.Stat(path) + if err != nil { + if pe, ok := err.(*os.PathError); ok && pe.Error == os.ENOENT && create { + err = os.MkdirAll(path, 0775) + if err != nil { + return nil, err + } + for _, subdir := range []string{"tmp", "cur", "new"} { + err = os.Mkdir(paths.Join(path, subdir), 0775) + if err != nil { + return nil, err + } + } + } else { + return nil, err + } + } + + return &Maildir{path}, nil +} + +// Open a maildir. If create is true and the maildir does not exist, create it. +func New(path string, create bool) (m *Maildir, err os.Error) { + // Ensure that path is not empty and ends with a / + if len(path) == 0 { + path = "." + string(paths.DirSeps[0]) + } else if !strings.Contains(paths.DirSeps, string(path[len(path)-1])) { + path += string(paths.DirSeps[0]) + } + return newWithRawPath(path, create) +} + +// Get a subfolder of the current folder. If create is true and the folder does not +// exist, create it. +func (m *Maildir) Child(name string, create bool) (*Maildir, os.Error) { + var i int + encodedPath := bytes.NewBufferString(m.path + ".") + for i = nextInvalidChar(name); i < len(name); i = nextInvalidChar(name) { + encodedPath.WriteString(name[:i]) + j := nextValidChar(name[i:]) + encode(name[i:i+j], encodedPath) + if j < len(name[i:]) { + name = name[i+j:] + } else { + name = "" + } + } + encodedPath.WriteString(name) + return newWithRawPath(encodedPath.String(), create) +} + +// Write a mail to the maildir folder. The data is not encoded or compressed in any way. +func (m *Maildir) CreateMail(data io.Reader) (filename string, err os.Error) { + hostname, err := os.Hostname() + if err != nil { + return "", err + } + + basename := fmt.Sprintf("%v.M%vP%v_%v.%v", time.Seconds(), time.Nanoseconds()/1000, os.Getpid(), <-counter, hostname) + tmpname := paths.Join(m.path, "tmp", basename) + file, err := os.Open(tmpname, os.O_WRONLY | os.O_CREAT, 0664) + if err != nil { + return "", err + } + + size, err := io.Copy(file, data) + if err != nil { + os.Remove(tmpname) + return "", err + } + + newname := paths.Join(m.path, "new", fmt.Sprintf("%v,S=%v", basename, size)) + err = os.Rename(tmpname, newname) + if err != nil { + os.Remove(tmpname) + return "", err + } + + return newname, nil +} + +// Valid (valid = has not to be escaped) chars = +// ASCII 32-127 + "&" + "/" + "." +// We disallow 32 (space) for obvious reasons (avoid to create folders with spaces into their names) +// We disallow 127 because the spec is ambiguous here: it allows 127 but not control characters, +// and 127 is a control character. +func isValidChar(b byte) bool { + if b <= 0x20 || b >= 127 { + return false + } + if b == byte('.') || b == byte('/') || b == byte('&') { + return false + } + return true +} + +func nextInvalidChar(s string) int { + for i := 0; i < len(s); i++ { + if !isValidChar(s[i]) { + return i + } + } + return len(s) +} + +func nextValidChar(s string) int { + for i := 0; i < len(s); i++ { + if isValidChar(s[i]) { + return i + } + } + return len(s) +} + +// s is a string of invalid chars, without any "&" +// An encoded sequence is composed of (Python-like pseudo-code): +// "&" + base64(rawSequence.encode('utf-16-be')).strip('=') + "-" +func encodeSequence(s string, buf *bytes.Buffer) { + utf16data := utf16.Encode([]int(s)) + utf16be := make([]byte, len(utf16data)*2) + for i := 0; i < len(utf16data); i++ { + utf16be[i*2] = byte(utf16data[i] >> 8) + utf16be[i*2+1] = byte(utf16data[i] & 0xff) + } + base64data := make([]byte, maildirBase64.EncodedLen(len(utf16be))+2) + maildirBase64.Encode(base64data[1:], utf16be) + endPos := bytes.IndexByte(base64data, byte('=')) + if endPos == -1 { + endPos = len(base64data) + } else { + endPos++ + } + base64data = base64data[:endPos] + base64data[0] = byte('&') + base64data[len(base64data)-1] = byte('-') + buf.Write(base64data) +} + +// s in a string of invalid chars +// "&" is not encoded in a sequence, and must be encoded as "&-", +// so split s as sequences of [^&]* separated by "&" characters +func encode(s string, buf *bytes.Buffer) { + if s[0] == byte('&') { + buf.WriteString("&-") + if len(s) > 1 { + encode(s[1:], buf) + } + } else { + i := strings.Index(s, "&") + if i != -1 { + encodeSequence(s[:i], buf) + encode(s[i:], buf) + } else { + encodeSequence(s, buf) + } + } +} diff --git a/maildir_test.go b/maildir_test.go new file mode 100644 index 0000000..4ad909c --- /dev/null +++ b/maildir_test.go @@ -0,0 +1,204 @@ +package maildir + +import ( + "testing" + "fmt" + "os" + "io/ioutil" + "strings" + "bytes" + "path" +) + +type encodingTestData struct { + decoded, encoded string +} + +var encodingTests = []encodingTestData{ + {"&2[foo]", "&-2[foo]"}, // Folder name starting with a special character + {"foo&", "foo&-"}, // Folder name ending with a special character + {"A./B", "A&AC4ALw-B"}, // "." and "/" are special + {"Lesson:日本語", "Lesson:&ZeVnLIqe-"}, // long sequence of characters + {"Résumé&Écritures", "R&AOk-sum&AOk-&-&AMk-critures"}, // "&" in the middle of a sequence of special characters +} + +func TestCreate(t *testing.T) { + if err := os.RemoveAll("_obj/Maildir"); err != nil { + panic(fmt.Sprintf("Can't remove old test data: %v", err)) + } + + // Opening non-existing maildir + md, err := New("_obj/Maildir", false) + if md != nil { + t.Errorf("I shouldn't be able to open a non-existent maildir") + return + } + + // Creating new maildir + md, err = New("_obj/Maildir", true) + defer os.RemoveAll("_obj/Maildir") + if err != nil { + t.Errorf("Error while creating maildir: %v", err) + return + } + if md == nil { + t.Errorf("No error, but nil maildir when creating a maildir") + return + } + + // Chek that cur/, tmp/ and new/ have been created + for _, subdir := range []string{"cur", "tmp", "new"} { + fi, err := os.Stat("_obj/Maildir/" + subdir) + if err != nil { + t.Errorf("Can't open %v of maildir _obj/Maildir: %v", subdir, err) + continue + } + if !fi.IsDirectory() { + t.Errorf("%v of maildir _obj/Maildir is not a directory", subdir) + continue + } + } +} + +func TestEncode(t *testing.T) { + if err := os.RemoveAll("_obj/Maildir"); err != nil { + panic(fmt.Sprintf("Can't remove old test data: %v", err)) + } + + maildir, err := New("_obj/Maildir", true) + if maildir == nil { + t.Errorf("Can't create maildir: %v", err) + return + } + defer os.RemoveAll("_obj/Maildir") + + for _, testData := range encodingTests { + child, err := maildir.Child(testData.decoded, true) + if err != nil { + t.Errorf("Can't create sub-maildir %v: %v", testData.decoded, err) + continue + } + if child.path != "_obj/Maildir/."+testData.encoded { + t.Logf("Sub-maildir %v has an invalid path", testData.decoded) + t.Logf(" Expected result: %s", "_obj/Maildir/."+testData.encoded) + t.Logf(" Actual result: %s", child.path) + t.Fail() + continue + } + } + + // Separator between sub-maildir and sub-sub-maildir should not be encoded + child, err := maildir.Child("foo", true) + if err != nil { + t.Errorf("Can't create sub-maildir foo: %v", err) + return + } + + child, err = child.Child("bar", true) + if err != nil { + t.Errorf("Can't create sub-maildir foo/bar: %v", err) + return + } + + if child.path != "_obj/Maildir/.foo.bar" { + t.Logf("Sub-maildir %v has an invalid path", "foo/bar") + t.Logf(" Expected result: %s", "_obj/Maildir/.foo.bar") + t.Logf(" Actual result: %s", child.path) + t.Fail() + } +} + +func readdirnames(dir string) ([]string, os.Error) { + d, err := os.Open(dir, os.O_RDONLY, 0) + if err != nil { + return nil, err + } + + list, err := d.Readdirnames(-1) + if err != nil { + return nil, err + } + + res := make([]string, 0, len(list)) + for _, entry := range list { + if entry != "." && entry != ".." { + res = append(res, entry) + } + } + + return res, nil +} + +func TestWrite(t *testing.T) { + if err := os.RemoveAll("_obj/Maildir"); err != nil { + panic(fmt.Sprintf("Can't remove old test data: %v", err)) + } + + maildir, err := New("_obj/Maildir", true) + if maildir == nil { + t.Errorf("Can't create maildir: %v", err) + return + } + defer os.RemoveAll("_obj/Maildir") + + testData := []byte("Hello, world !") + + // write a mail + fullName, err := maildir.CreateMail(bytes.NewBuffer(testData)) + if err != nil { + t.Errorf("Can't create mail: %v", err) + } + + // tmp/ and cur/ must be empty + names, err := readdirnames("_obj/Maildir/tmp") + if err != nil { + t.Errorf("Can't read tmp/: %v", err) + return + } + if len(names) > 0 { + t.Errorf("Expected no element in tmp/, got %v", names) + } + + names, err = readdirnames("_obj/Maildir/cur") + if err != nil { + t.Errorf("Can't read cur/: %v", err) + return + } + if len(names) > 0 { + t.Errorf("Expected no element in cur/, got %v", names) + } + + // new/ must contain only one file, which must contain the written data + names, err = readdirnames("_obj/Maildir/new") + if err != nil { + t.Errorf("Can't read new/: %v", err) + return + } + if len(names) != 1 { + t.Errorf("Expected one element in new/, got %v", names) + } + + f, err := os.Open(fullName, os.O_RDONLY, 0) + if err != nil { + t.Errorf("Can't open %v: %v", fullName, err) + return + } + data, err := ioutil.ReadAll(f) + if err != nil { + t.Errorf("Can't read %v: %v", fullName, err) + return + } + + if bytes.Compare(data, testData) != 0 { + t.Errorf("File contains %#v, expected %#v", string(data), string(testData)) + } + + // filename must end with ,S=(mail size) + name := names[0] + if !strings.HasSuffix(name, fmt.Sprintf(",S=%d", len(testData))) { + t.Errorf("Filename %#v must end with %#v", name, fmt.Sprintf(",S=%d", len(testData))) + } + if path.Base(fullName) != name { + t.Errorf("Returned name %#v does not match #%v", path.Base(fullName), name) + } +}