# HG changeset patch # User telesto # Date 1509381989 -3600 # Mon Oct 30 17:46:29 2017 +0100 # Node ID 9dbe074f6020071c17c5e9f74496d8f4bee4eb94 # Parent 1f44b88e8d13547109d91c1126915127aee410c6 lots of things diff --git a/cmd/mb/main.go b/cmd/mb/main.go --- a/cmd/mb/main.go +++ b/cmd/mb/main.go @@ -70,7 +70,7 @@ } case "ls": // todo: allow sequence - folder := mbox.Default() + folder := mbox.DefaultFolder if len(os.Args) > 2 { folder = os.Args[2] } @@ -106,6 +106,7 @@ } err = src.Close() case "part": + // todo: accept sequence if len(os.Args) < 3 { err = missingParams break @@ -170,6 +171,7 @@ err = err1 } case "parts": + // bug: does not work anymore // todo: read sequence from stdin if len(os.Args) < 3 { // todo: read stdin @@ -178,8 +180,7 @@ } var folder string var msgs *mb.Sequence - folder, msgs, err = parseSequence(mbox, os.Args[2]) - if err != nil { + if folder, msgs, err = parseSequence(mbox, os.Args[2]); err != nil { break } var parts []string @@ -192,29 +193,29 @@ } case "grep": // syntax: pick regexp -// if len(os.Args) < 3 { -// err = missingParams -// break -// } -// var folder string -// var match *mb.Regexp -// if folder, match, err = parseRegexpCmd(os.Args[2:]); err != nil { -// break -// } -// var msgs []mh.Message -// if msgs, errors = mbox.ReadFolder(folder); err != nil { -// break -// } -// var picked []string -// for i := range msgs { -// if match.Matches(msgs[i]) { -// picked = append(picked, strconv.FormatInt(msgs[i].Id, 10)) -// } -// } -// if len(picked) > 0 { -// // main must not know how a filename is created TODO -// os.Stdout.WriteString(path.Join(folder, strings.Join(picked, ",")) + "\n") -// } + // if len(os.Args) < 3 { + // err = missingParams + // break + // } + // var folder string + // var match *mb.Regexp + // if folder, match, err = parseRegexpCmd(os.Args[2:]); err != nil { + // break + // } + // var msgs []mh.Message + // if msgs, errors = mbox.ReadFolder(folder); err != nil { + // break + // } + // var picked []string + // for i := range msgs { + // if match.Matches(msgs[i]) { + // picked = append(picked, strconv.FormatInt(msgs[i].Id, 10)) + // } + // } + // if len(picked) > 0 { + // // main must not know how a filename is created TODO + // os.Stdout.WriteString(path.Join(folder, strings.Join(picked, ",")) + "\n") + // } case "new": var name string if name, err = mb.Create(mbox); len(name) > 0 { @@ -238,7 +239,7 @@ } case "detach": // todo. - os.Stdout.WriteString("todo!\n") + os.Stdout.WriteString("todo\n") case "reply": // is a reply-all var src io.ReadCloser = os.Stdin if len(os.Args) > 2 { @@ -321,7 +322,7 @@ if err != nil { break } - err = mb.Move(mbox, folder, msgs, mbox.Sent()) + err = mb.Move(mbox, folder, msgs, mbox.Sent) case "mv": // todo: defaultfolder if len(os.Args) < 4 { @@ -352,7 +353,7 @@ err = errors.New(cmd + ": not implemented") case "store": // todo: document that a maildrop mail needs to - // ne piped through grep -v "^From " to remove the + // be piped through grep -v "^From " to remove the // leading "From " line if len(os.Args) < 3 { err = missingParams @@ -379,13 +380,13 @@ } func openMsg(mbox *mh.Mailbox, msg string) (io.ReadCloser, error) { - f, b := path.Split(msg) + f, m := path.Split(msg) if len(f) == 0 { - f = mbox.Default() + f = mbox.DefaultFolder } - id, err := strconv.ParseInt(b, 10, 64) + id, err := strconv.ParseInt(m, 10, 64) if err != nil { - return nil, err + return nil, fmt.Errorf("%s: invalid msg id", m) } return mbox.Open(f, id) } @@ -395,7 +396,7 @@ func parseSequence(mbox *mh.Mailbox, value string) (string, *mb.Sequence, error) { folder, seq := path.Split(value) if len(folder) == 0 { - folder = mbox.Default() + folder = mbox.DefaultFolder } msgs, err := mb.ParseSequence(seq) if err != nil { diff --git a/command.go b/command.go --- a/command.go +++ b/command.go @@ -22,29 +22,42 @@ func walk(mbox *mh.Mailbox, folder string, seq *Sequence, proc func(int64) error) error { // we need a sorted list, so the "Move" cmd will process the msgs // in the correct time order + + // todo: read dir only, not all messages! + // best would be: + // msgs, errors := mbox.ReadFolder(folder, seq) + // then let proc act on msg, like proc(*mail.Message) error + // todo: use this in List command! msgs, errors := mbox.ReadFolder(folder) var err error - select { - case msg, ok := <-msgs: - if !ok { - return err - } - if seq.IsMatch(msg.Id) { - if err1 := proc(msg.Id); err == nil { + for { + select { + case msg, ok := <-msgs: + if !ok { + return err + } + if seq.IsMatch(msg.Id) { + if err1 := proc(msg.Id); err == nil { + // parts: return this, so it is not written at the end + // of the list to stderr, like: + // inbox/17:1 text/plain; charset=utf-8 + // inbox/18:1 text/plain; charset=utf-8 + // inbox/19:1 text/plain; charset=UTF-8 + // inbox/20:1 text/plain; charset="ISO-8859-1" + // mailbox: inbox/16: mime: no media type + err = fmt.Errorf("%s/%d: %v", folder, msg.Id, err1) + } + } + case err1 := <-errors: + if err == nil { err = err1 } } - case err1, ok := <-errors: - if !ok { - return err - } - if err == nil { - err = err1 - } } return err } +// todo: provide a set of msgs/parts by prematching all msgs of a folder func Move(mbox *mh.Mailbox, srcFolder string, msgs *Sequence, dst string) error { proc := func(id int64) error { return mbox.Move(srcFolder, dst, id) @@ -52,6 +65,7 @@ return walk(mbox, srcFolder, msgs, proc) } +// todo: provide a set of msgs/parts by prematching all msgs of a folder func Remove(mbox *mh.Mailbox, folder string, msgs *Sequence) error { proc := func(id int64) error { return mbox.Remove(folder, id) @@ -59,40 +73,42 @@ return walk(mbox, folder, msgs, proc) } -func partError(no int, msg string) error { - return errors.New("mime part " + strconv.Itoa(no) + ": " + msg) -} - // todo: implement matcher +// todo: provide a set of msgs/parts by prematching all msgs of a folder, maybe as channel +// todo: return saved filenames in a channel +// todo: do we need to decode the filename? func Save(msg *mail.Message, number int) ([]string, error) { var files []string saver := func(p part, no int) error { if no != number { return nil } + partError := func(err error) error { + return errors.New("mime part " + strconv.Itoa(no) + ": " + err.Error()) + } mp, ok := p.Body.(*multipart.Part) if !ok { - return partError(no, "not an attached file") + return partError(errors.New("not an attached file")) } filename := mp.FileName() if len(filename) == 0 { - return partError(no, "no filename") + return partError(errors.New("no filename")) } f, err := os.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0666) if err != nil { - return partError(no, err.Error()) + return partError(err) } defer f.Close() in, err := mimeDecoder(p) if err != nil { - return partError(no, err.Error()) + return partError(err) } if _, err = io.Copy(f, in); err != nil { - return partError(no, err.Error()) + return partError(err) } files = append(files, f.Name()) if err = f.Close(); err != nil { - return partError(no, err.Error()) + return partError(err) } return nil } @@ -100,7 +116,7 @@ } // todo: decode "name" - +// todo: provide a set of msgs/parts by prematching all msgs of a folder func Parts(mbox *mh.Mailbox, folder string, msgs *Sequence) ([]string, error) { var parts []string proc := func(id int64) error { @@ -113,7 +129,7 @@ return err } partsLister := func(p part, no int) error { - parts = append(parts, fmt.Sprintf("%s/%d:%d %s", folder, id, no, p.Header.Get("Content-Type"))) + parts = append(parts, fmt.Sprintf("%s/%d:%d %s", folder, id, no, p.Header.Get("Content-Type"))) return nil } return parse(msg, partsLister) @@ -121,6 +137,7 @@ return parts, walk(mbox, folder, msgs, proc) } +// todo: provide a set of msgs/parts by prematching all msgs of a folder func ViewPart(dst io.Writer, msg *mail.Message, number int) error { partPrinter := func(p part, no int) error { if no != number { @@ -141,6 +158,7 @@ return err } +// we have a writer twice, dst and anno parameter func newPlainTextDecoder(dst io.Writer, t transform.Transformer, anno func(io.Writer, part, int) error) func(part, int) error { return func(p part, no int) error { // todo: if the msg has no content type header, this will return "mime: no media type" @@ -167,6 +185,7 @@ // if the mail has only one header line without a nl at the end, "mailbox: EOF" // is thrown +// todo: provide a set of msgs/parts by prematching all msgs of a folder func View(dst io.Writer, msg *mail.Message) error { var b bytes.Buffer for _, key := range viewHeader { diff --git a/config.go b/config.go --- a/config.go +++ b/config.go @@ -20,18 +20,20 @@ // editor is a program to edit mails. // // This editor has to be UTF-8 capable. - editor = "vim" + editor = "/usr/bin/nvi" // viewHeader holds a list of mail header fields, as described in RFC 5322. // These fields and their values will be shown when viewing a mail. viewHeader = []string{"From", "To", "Cc", "Subject", "Date"} + // todo: use this below listDelim = " | " + // formats holds the listing format for the various folder types // (as used in the list command). // // For help on these formats see the documentation of the fmt package. // Argument Indexes: - // 1:message id, 2: msg flags, 3: date, 4: from, 5: to, 6: subject + // 1: message id, 2: msg flags, 3: date, 4: from, 5: to, 6: subject formats = map[int]string{ // show all values inOutbox: "%5[1]d | %[2]s | %[3]s | %-15.15[4]s | %-15.15[5]s | %-50.50[6]s", @@ -43,8 +45,6 @@ // DefaultFolderType is the folder format for unregistered folders. defaultFolderType = inbox - listDelim = " | " - // todo: sendmailConfig = "/home/schlichti/.etc/ssmtp/mailbox.gmx.conf" ) diff --git a/decode.go b/decode.go --- a/decode.go +++ b/decode.go @@ -1,43 +1,70 @@ package mailbox import ( - "errors" "io" "mime" + "net/mail" "strings" "golang.org/x/text/encoding/charmap" "golang.org/x/text/transform" ) -type decoder struct { - *mime.WordDecoder -} - var dec = &mime.WordDecoder{CharsetReader: func(charset string, input io.Reader) (io.Reader, error) { - cr, err := decodeCharSet(charset) - if err != nil { - return nil, err + var tr transform.Transformer = transform.Nop + switch strings.ToLower(charset) { + case "iso-8859-1", "windows-1252": + tr = charmap.Windows1252.NewDecoder() + case "iso-8859-15": + tr = charmap.ISO8859_15.NewDecoder() + case "us-ascii": // actually, we need to replace us ascii > 0x7f with something? } - return transform.NewReader(input, cr), nil + return transform.NewReader(input, tr), nil }} -func safeDecodeHeader(key string) string { - h, err := dec.DecodeHeader(key) +func safeDecodeHeader(s string) string { + h, err := dec.DecodeHeader(s) if err != nil { h = "Error: " + err.Error() } return h } -func decodeCharSet(charset string) (transform.Transformer, error) { - switch strings.ToLower(charset) { - case "iso-8859-1", "windows-1252": - return charmap.Windows1252.NewDecoder(), nil - case "iso-8859-15": - return charmap.ISO8859_15.NewDecoder(), nil - case "utf-8", "us-ascii": // actually, we need to replace us ascii > 0x7f with something - return transform.Nop, nil +func ReadMessage(r io.Reader) (*Message, error) { + msg, err := mail.ReadMessage(r) + if err != nil { + return nil, err } - return nil, errors.New("unknown charset '" + charset + "'") + return &Message{NewHeader(msg.Header), msg.Body}, nil +} + +type Message struct { + Header Header + Body io.Reader +} + +// todo: mime.header decoden? +type Header struct { + mail.Header + dec *mime.WordDecoder +} + +func NewHeader(h mail.Header) Header { + return Header{h, dec} } + +func (h Header) Decode(key string) string { + v, err := h.dec.DecodeHeader(h.Get(key)) + if err != nil { + v = "Error: " + err.Error() + } + return v +} + +func (h Header) DecodeAddresses(key string) []*mail.Address { + list, err := h.AddressList(key) + if err != nil { + list = []*mail.Address{&mail.Address{Name: "Error: " + err.Error()}} + } + return list +} diff --git a/edit.go b/edit.go --- a/edit.go +++ b/edit.go @@ -82,7 +82,7 @@ if err != nil { return "", err } - if err = format(tmp, mbox.From(), msg); err != nil { + if err = format(tmp, mbox.Mailaddress, msg); err != nil { tmp.Close() os.Remove(tmp.Name()) return "", err @@ -122,14 +122,14 @@ wait.Add(1) go func() { defer wait.Done() - msgid, err = mbox.Append(mbox.Draft(), r) + msgid, err = mbox.Append(mbox.Draft, r) r.Close() }() err1 := encodeMessage(w, newMsg) // todo: error handling w.Close() wait.Wait() - msgfile := path.Join(mbox.Draft(), msgid) + msgfile := path.Join(mbox.Draft, msgid) if err == nil { err = err1 } diff --git a/list.go b/list.go --- a/list.go +++ b/list.go @@ -10,17 +10,16 @@ "bitbucket.org/telesto/mailbox/mh" ) -var sixMonthPast = time.Now().Add(-6 * 30 * 24 * time.Hour) -var oneDayAhead = time.Now().Add(24 * time.Hour) - // todo: delim as param func List(mbox *mh.Mailbox, out io.Writer, folder string, pick *Sequence) error { - fType, ok := mbox.FolderType(folder) + fType, ok := mbox.Folders[folder] if !ok { fType = defaultFolderType } var err error msgs, errors := mbox.ReadFolder(folder) + sixMonthPast := time.Now().Add(-6 * 30 * 24 * time.Hour) + oneDayAhead := time.Now().Add(24 * time.Hour) for { select { case msg, ok := <-msgs: @@ -30,25 +29,23 @@ if !pick.IsMatch(msg.Id) { continue } - header := msg.Header + header := NewHeader(msg.Header) + // header := msg.Header // ignore error, we can't do anything about it date, _ := header.Date() format := "Jan _2 15:04" if date.Before(sixMonthPast) || date.After(oneDayAhead) { - format = "Jan _2 2006" + format = "Jan _2 2006" } // this was a deleted flag, we dont need that for now flags := " " fmt.Fprintf(out, formats[fType], msg.Id, flags, date.Format(format), - decodeAddressList(header.Get("From")), - decodeAddressList(header.Get("To")), - safeDecodeHeader(header.Get("Subject"))) + listAddresses(header.DecodeAddresses("From")), + listAddresses(header.DecodeAddresses("To")), + header.Decode("Subject")) out.Write([]byte{'\n'}) - case err1, ok := <-errors: - if !ok { - return err - } + case err1 := <-errors: if err == nil { err = err1 } @@ -58,19 +55,17 @@ } // this function returns the names only! -func decodeAddressList(list string) string { +// todo: merge with edit.go:parseAddressList +// put this and safeDecodeHeader() into an header struct +func listAddresses(list []*mail.Address) string { if len(list) == 0 { return "" } - addrList, err := mail.ParseAddressList(list) - if err != nil { - return "Error: " + err.Error() - } var addresses []string - for i := range addrList { - address := addrList[i].Name + for i := range list { + address := list[i].Name if len(address) == 0 { - address = addrList[i].Address + address = list[i].Address } addresses = append(addresses, address) } diff --git a/mh/mh.go b/mh/mh.go --- a/mh/mh.go +++ b/mh/mh.go @@ -5,7 +5,6 @@ import ( "errors" "io" - "io/ioutil" "net/mail" "os" "path" @@ -28,26 +27,21 @@ outbox ) -type Message struct { - Id int64 - *mail.Message -} - // todo: maildir only, the rest is for account // we want to be able to use Mailbox by only providing the mail dir type Mailbox struct { // MailDir points to the MH Maildir. - mailDir, + Maildir, // Mailaddress holds the address of the sender. - mailAddress, + Mailaddress, // if no folder for a msg is given this folder is used as default - defaultf string + DefaultFolder string // Draft is the folder in which msg are put before being send. - draft string + Draft string // Sent is a folder which holds sent messages. - sent string + Sent string - folders map[string]int + Folders map[string]int } func NewMailbox() *Mailbox { @@ -58,14 +52,13 @@ } func (mh *Mailbox) osPath(elem ...string) string { - return path.Join(mh.mailDir, strings.Join(elem, "/")) + return path.Join(mh.Maildir, strings.Join(elem, "/")) } func (mh *Mailbox) msgFile(folder string, msgid int64) string { - return path.Join(mh.mailDir, folder, strconv.FormatInt(msgid, 10)) + return mh.osPath(folder, strconv.FormatInt(msgid, 10)) } -// todo: we sort mails in ReadFolder, could use this here func (mh *Mailbox) nextId(folder string) (int64, error) { if err := mh.lock(); err != nil { return 0, err @@ -76,44 +69,43 @@ if err != nil { return 0, err } - var max int64 - for i := range list { - // we even consider directories, so we do not create a - // file with a name of an existing directory - id, err := strconv.ParseInt(list[i].Name(), 10, 64) - if err != nil { - // ignore every file that apparently is not a mail - continue - } - if id > max { - max = id - } + if len(list) == 0 { + return 1, nil } - return max, nil + return list[len(list)-1].Id + 1, nil } -func (mbox Mailbox) FolderType(folder string) (int, bool) { - t, ok := mbox.folders[folder] - return t, ok +type Message struct { + Id int64 + *mail.Message } -func (mbox Mailbox) Folders(parent string) ([]string, error) { - files, err := ioutil.ReadDir(mbox.osPath(parent)) +// returns a sorted list, can be a number file or a number dir +func (mh *Mailbox) readDir(folder string) ([]numberInode, error) { + + f, err := os.Open(mh.osPath(folder)) if err != nil { return nil, err } - var folders []string - for _, file := range files { - if file.IsDir() { - folders = append(folders, file.Name()) + defer f.Close() + files, err := f.Readdir(-1) + if err != nil { + return nil, err + } + var msgs []numberInode + for i := 0; i < len(files); i++ { + // ignore every file with a filename not a number + if id, err := strconv.ParseInt(files[i].Name(), 10, 0); err == nil { + msgs = append(msgs, numberInode{files[i], id}) } } - return folders, nil + sort.Slice(msgs, func(i, j int) bool { return msgs[i].Id < msgs[j].Id }) + return msgs, f.Close() } // returns a sorted list func (mbox Mailbox) ReadFolder(folder string) (chan Message, chan error) { - mails := make(chan Message, 20) + mails := make(chan Message) errors := make(chan error) go func() { defer close(errors) @@ -128,11 +120,7 @@ } // continue on error, show what we got for i := range list { - msgid, err := strconv.ParseInt(list[i].Name(), 10, 64) - if err != nil { - continue - } - file, err := mbox.open(folder, msgid) + file, err := mbox.open(folder, list[i].Id) if err != nil { // save error msg in subject instead of returning error mails <- errMail(list[i].Id, err) @@ -154,30 +142,6 @@ return mails, errors } -// returns a sorted list -func (mh *Mailbox) readDir(folder string) ([]msgFile, error) { - - f, err := os.Open(mh.osPath(folder)) - if err != nil { - return nil, err - } - defer f.Close() - files, err := f.Readdir(-1) - if err != nil { - return nil, err - } - l := len(files) - var msgs []msgFile - for i := 0; i < l; i++ { - // ignore every file that is not a mail - if id, err := strconv.ParseInt(files[i].Name(), 10, 0); err == nil && !files[i].IsDir() { - msgs = append(msgs, msgFile{files[i], id}) - } - } - sort.Sort(byId{msgs}) - return msgs, f.Close() -} - func (mh *Mailbox) Append(folder string, msg io.Reader) (newMsg string, err error) { if err := mh.lock(); err != nil { return "", err @@ -191,14 +155,14 @@ var file *os.File file, err = os.OpenFile(mh.msgFile(folder, id), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) if err != nil { - return + return newMsg, err } if _, err = io.Copy(file, msg); err != nil { file.Close() - return + return newMsg, err } if err = file.Close(); err != nil { - return + return newMsg, err } return mh.msgFile(folder, id), mh.unlock() } @@ -206,8 +170,8 @@ func errMail(id int64, err error) Message { h := make(map[string][]string, 1) h["Subject"] = []string{"Error: " + err.Error()} - b := strings.NewReader("Error: " + err.Error()) - return Message{id, &mail.Message{h, b}} + body := strings.NewReader("Error: " + err.Error()) + return Message{id, &mail.Message{h, body}} } func (mh *Mailbox) MkFolder(folder string) error { @@ -221,19 +185,7 @@ return mh.unlock() } -// sorter implements sort.Interface. -type sorter []msgFile - -func (f sorter) Len() int { return len(f) } -func (f sorter) Swap(i, j int) { f[i], f[j] = f[j], f[i] } - -type byId struct { - sorter -} - -func (f byId) Less(i, j int) bool { return f.sorter[i].Id < f.sorter[j].Id } - -type msgFile struct { +type numberInode struct { os.FileInfo Id int64 } @@ -243,6 +195,7 @@ } // todo: sort by date, use case: very old msg is moved into a folder +// todo: this will fail if there is a number dir func (mh *Mailbox) Pack(folder string) error { if err := mh.lock(); err != nil { return err @@ -334,8 +287,7 @@ func (mh *Mailbox) lock() error { return nil - lock, err := os.OpenFile(mh.osPath(lockfile), - os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + lock, err := os.OpenFile(mh.osPath(lockfile), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) if err != nil { if os.IsExist(err) { return errors.New("mailbox is locked") @@ -361,20 +313,3 @@ } return nil } - -func (mh *Mailbox) Default() string { - return mh.defaultf -} - -func (mh *Mailbox) Draft() string { - return mh.draft -} - -func (mh *Mailbox) Sent() string { - return mh.sent -} - -func (mh *Mailbox) From() string { - return mh.mailAddress - -} diff --git a/sequence.go b/sequence.go --- a/sequence.go +++ b/sequence.go @@ -65,6 +65,7 @@ return &Sequence{section: [][2]int64{[2]int64{0, 1<<63 - 1}}} } +// todo: not used type Regexp struct { matchHeader func(string) bool matchBody bool diff --git a/sequence_test.go b/sequence_test.go --- a/sequence_test.go +++ b/sequence_test.go @@ -6,12 +6,13 @@ seq string parseErr bool number int64 - want bool + isMatch bool }{ {"123", false, 123, true}, {"56,144", false, 56, true}, {"22-33", false, 22, true}, {"22-33", false, 23, true}, + {"22-33", false, 34, false}, {"abc", true, 0, false}, {"-", true, 0, false}, {",", true, 0, false}, @@ -26,6 +27,7 @@ {"44-56", false, 47, true}, {"12,14-16", false, 15, true}, {"1-6,45-55,80-90", false, 7, false}, + {"1-6,45-55,80-90", false, 50, true}, {"1-6,45-55,80-90", false, 55, true}, } @@ -37,10 +39,11 @@ i, err, tc.parseErr) } if err == nil { - got := s.Matches(tc.number) - if got != tc.want { + got := s.IsMatch(tc.number) + want := tc.isMatch + if got != want { t.Errorf("%d: wrong matching, found '%t', expected %t", - i, got, tc.want) + i, got, want) } } } diff --git a/testdata/parts/1 b/testdata/parts/1 new file mode 100644 --- /dev/null +++ b/testdata/parts/1 @@ -0,0 +1,19 @@ +Content-Transfer-Encoding: 7bit +Content-Type: multipart/mixed; + boundary=Apple-Mail-B355CC53-B5E6-4AC7-805C-66B3AA273978 +Mime-Version: 1.0 (1.0) + +--Apple-Mail-B355CC53-B5E6-4AC7-805C-66B3AA273978 +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: quoted-printable + +text + +--Apple-Mail-B355CC53-B5E6-4AC7-805C-66B3AA273978 +Content-Type: image/jpeg; name=IMG_4993.JPG; +Content-Disposition: inline; filename=IMG_4993.JPG +Content-Transfer-Encoding: base64 + +image + +--Apple-Mail-B355CC53-B5E6-4AC7-805C-66B3AA273978--