mbox: implement an mbox backend worker

Implement an mbox backend worker. Worker can be used for testing and
development by mocking a backend for the message store. Worker does not
modify the actual mbox file on disk; all operations are performed in
memory.

To use the mbox backend, create an mbox account in the accounts.conf
where the source uses the "mbox://" scheme, such as

source = mbox://~/mbox/

or

source = mbox://~/mbox/file.mbox

If the mbox source points to a directory, all files in this directory
with the .mbox suffix will be opened as folders.

If an outgoing smtp server is defined for the mbox account, replies can
be sent to emails that are stored in the mbox file.

Signed-off-by: Koni Marti <koni.marti@gmail.com>
Acked-by: Robin Jarry <robin@jarry.cc>
This commit is contained in:
Koni Marti 2022-07-11 20:11:18 +02:00 committed by Robin Jarry
parent 12dec19109
commit a1a276e002
8 changed files with 971 additions and 8 deletions

4
go.mod
View file

@ -12,8 +12,9 @@ require (
github.com/emersion/go-imap v1.2.0
github.com/emersion/go-imap-sortthread v1.2.0
github.com/emersion/go-maildir v0.2.0
github.com/emersion/go-mbox v1.0.2
github.com/emersion/go-message v0.15.0
github.com/emersion/go-msgauth v0.6.5 // indirect
github.com/emersion/go-msgauth v0.6.5
github.com/emersion/go-pgpmail v0.2.0
github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac
github.com/emersion/go-smtp v0.15.0
@ -26,7 +27,6 @@ require (
github.com/imdario/mergo v0.3.12
github.com/kyoh86/xdg v1.2.0
github.com/lithammer/fuzzysearch v1.1.3
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.14
github.com/mattn/go-pointer v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.13

21
go.sum
View file

@ -45,6 +45,7 @@ github.com/arran4/golang-ical v0.0.0-20220517104411-fd89fefb0182/go.mod h1:BSTTr
github.com/brunnre8/go.notmuch v0.0.0-20201126061756-caa2daf7093c h1:dh58QrW3/S/aCnQPFoeRRE9zMauKooDFd5zh1dLtxXs=
github.com/brunnre8/go.notmuch v0.0.0-20201126061756-caa2daf7093c/go.mod h1:zJtFvR3NinVdmBiLyB4MyXKmqyVfZEb2cK97ISfTgV8=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
@ -68,6 +69,8 @@ github.com/emersion/go-imap-sortthread v1.2.0 h1:EMVEJXPWAhXMWECjR82Rn/tza6Mddcv
github.com/emersion/go-imap-sortthread v1.2.0/go.mod h1:UhenCBupR+vSYRnqJkpjSq84INUCsyAK1MLpogv14pE=
github.com/emersion/go-maildir v0.2.0 h1:fC4+UVGl8GcQGbFF7AWab2JMf4VbKz+bMNv07xxhzs8=
github.com/emersion/go-maildir v0.2.0/go.mod h1:I2j27lND/SRLgxROe50Vam81MSaqPFvJ0OHNnDZ7n84=
github.com/emersion/go-mbox v1.0.2 h1:tE/rT+lEugK9y0myEymCCHnwlZN04hlXPrbKkxRBA5I=
github.com/emersion/go-mbox v1.0.2/go.mod h1:Yp9IVuuOYLEuMv4yjgDHvhb5mHOcYH6x92Oas3QqEZI=
github.com/emersion/go-message v0.11.1/go.mod h1:C4jnca5HOTo4bGN9YdqNQM9sITuT3Y0K6bSUw9RklvY=
github.com/emersion/go-message v0.11.2/go.mod h1:C4jnca5HOTo4bGN9YdqNQM9sITuT3Y0K6bSUw9RklvY=
github.com/emersion/go-message v0.14.1/go.mod h1:N1JWdZQ2WRUalmdHAX308CWBq747VJ8oUorFI3VCBwU=
@ -106,7 +109,9 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-ini/ini v1.63.2 h1:kwN3umicd2HF3Tgvap4um1ZG52/WyKT9GGdPx0CJk6Y=
github.com/go-ini/ini v1.63.2/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-test/deep v1.0.7 h1:/VSMRlnY/JSyqxQUzQLKVMAskpY/NZKFA5j2P+0pP2M=
github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28 h1:gBeyun7mySAKWg7Fb0GOcv0upX9bdaZScs8QcRo8mEY=
github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@ -164,20 +169,22 @@ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU=
github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7 h1:g0fAGBisHaEQ0TRq1iBvemFRf+8AEWEmBESSiWB3Vsc=
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jhillyerd/enmime v0.9.1 h1:HcC2WZA6dMCobs8WeyF/6FRSvdRCrr8O+UiLBae4eNE=
github.com/jhillyerd/enmime v0.9.1/go.mod h1:S5ge4lnv/dDDBbAWwtoOFlj14NHiXdw/EqMB2lJz3b8=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
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/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kyoh86/xdg v1.2.0 h1:CERuT/ShdTDj+A2UaX3hQ3mOV369+Sj+wyn2nIRIIkI=
github.com/kyoh86/xdg v1.2.0/go.mod h1:/mg8zwu1+qe76oTFUBnyS7rJzk7LLC0VGEzJyJ19DHs=
@ -202,10 +209,14 @@ github.com/miolini/datacounter v1.0.2 h1:mGTL0vqEAtH7mwNJS1JIpd6jwTAP6cBQQ2P8apa
github.com/miolini/datacounter v1.0.2/go.mod h1:C45dc2hBumHjDpEU64IqPwR6TDyPVpzOqqRTN7zmBUA=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@ -219,10 +230,10 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ
github.com/riywo/loginshell v0.0.0-20200815045211-7d26008be1ab h1:ZjX6I48eZSFetPb41dHudEyVr5v953N15TsNZXlkcWY=
github.com/riywo/loginshell v0.0.0-20200815045211-7d26008be1ab/go.mod h1:/PfPXh0EntGc3QAAyUaviy4S9tzy4Zp0e2ilq4voC6E=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY=
@ -491,11 +502,13 @@ google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ
google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

254
worker/lib/search.go Normal file
View file

@ -0,0 +1,254 @@
package lib
import (
"io/ioutil"
"net/textproto"
"strings"
"unicode"
"git.sr.ht/~sircmpwn/getopt"
"git.sr.ht/~rjarry/aerc/models"
)
type searchCriteria struct {
Header textproto.MIMEHeader
Body []string
Text []string
WithFlags []models.Flag
WithoutFlags []models.Flag
}
func GetSearchCriteria(args []string) (*searchCriteria, error) {
criteria := &searchCriteria{Header: make(textproto.MIMEHeader)}
opts, optind, err := getopt.Getopts(args, "rux:X:bat:H:f:c:")
if err != nil {
return nil, err
}
body := false
text := false
for _, opt := range opts {
switch opt.Option {
case 'r':
criteria.WithFlags = append(criteria.WithFlags, models.SeenFlag)
case 'u':
criteria.WithoutFlags = append(criteria.WithoutFlags, models.SeenFlag)
case 'x':
criteria.WithFlags = append(criteria.WithFlags, getParsedFlag(opt.Value))
case 'X':
criteria.WithoutFlags = append(criteria.WithoutFlags, getParsedFlag(opt.Value))
case 'H':
// TODO
case 'f':
criteria.Header.Add("From", opt.Value)
case 't':
criteria.Header.Add("To", opt.Value)
case 'c':
criteria.Header.Add("Cc", opt.Value)
case 'b':
body = true
case 'a':
text = true
}
}
if text {
criteria.Text = args[optind:]
} else if body {
criteria.Body = args[optind:]
} else {
for _, arg := range args[optind:] {
criteria.Header.Add("Subject", arg)
}
}
return criteria, nil
}
func getParsedFlag(name string) models.Flag {
var f models.Flag
switch strings.ToLower(name) {
case "seen":
f = models.SeenFlag
case "answered":
f = models.AnsweredFlag
case "flagged":
f = models.FlaggedFlag
}
return f
}
func Search(messages []RawMessage, criteria *searchCriteria) ([]uint32, error) {
requiredParts := getRequiredParts(criteria)
matchedUids := []uint32{}
for _, m := range messages {
success, err := searchMessage(m, criteria, requiredParts)
if err != nil {
return nil, err
} else if success {
matchedUids = append(matchedUids, m.UID())
}
}
return matchedUids, nil
}
// searchMessage executes the search criteria for the given RawMessage,
// returns true if search succeeded
func searchMessage(message RawMessage, criteria *searchCriteria,
parts MsgParts) (bool, error) {
// setup parts of the message to use in the search
// this is so that we try to minimise reading unnecessary parts
var (
flags []models.Flag
header *models.MessageInfo
body string
all string
err error
)
if parts&FLAGS > 0 {
flags, err = message.ModelFlags()
if err != nil {
return false, err
}
}
if parts&HEADER > 0 {
header, err = MessageInfo(message)
if err != nil {
return false, err
}
}
if parts&BODY > 0 {
// TODO: select body properly; this is just an 'all' clone
reader, err := message.NewReader()
if err != nil {
return false, err
}
defer reader.Close()
bytes, err := ioutil.ReadAll(reader)
if err != nil {
return false, err
}
body = string(bytes)
}
if parts&ALL > 0 {
reader, err := message.NewReader()
if err != nil {
return false, err
}
defer reader.Close()
bytes, err := ioutil.ReadAll(reader)
if err != nil {
return false, err
}
all = string(bytes)
}
// now search through the criteria
// implicit AND at the moment so fail fast
if criteria.Header != nil {
for k, v := range criteria.Header {
headerValue := header.RFC822Headers.Get(k)
for _, text := range v {
if !containsSmartCase(headerValue, text) {
return false, nil
}
}
}
}
if criteria.Body != nil {
for _, searchTerm := range criteria.Body {
if !containsSmartCase(body, searchTerm) {
return false, nil
}
}
}
if criteria.Text != nil {
for _, searchTerm := range criteria.Text {
if !containsSmartCase(all, searchTerm) {
return false, nil
}
}
}
if criteria.WithFlags != nil {
for _, searchFlag := range criteria.WithFlags {
if !containsFlag(flags, searchFlag) {
return false, nil
}
}
}
if criteria.WithoutFlags != nil {
for _, searchFlag := range criteria.WithoutFlags {
if containsFlag(flags, searchFlag) {
return false, nil
}
}
}
return true, nil
}
// containsFlag returns true if searchFlag appears in flags
func containsFlag(flags []models.Flag, searchFlag models.Flag) bool {
match := false
for _, flag := range flags {
if searchFlag == flag {
match = true
}
}
return match
}
// containsSmartCase is a smarter version of strings.Contains for searching.
// Is case-insensitive unless substr contains an upper case character
func containsSmartCase(s string, substr string) bool {
if hasUpper(substr) {
return strings.Contains(s, substr)
}
return strings.Contains(strings.ToLower(s), strings.ToLower(substr))
}
func hasUpper(s string) bool {
for _, r := range s {
if unicode.IsUpper(r) {
return true
}
}
return false
}
// The parts of a message, kind of
type MsgParts int
const NONE MsgParts = 0
const (
FLAGS MsgParts = 1 << iota
HEADER
BODY
ALL
)
// Returns a bitmask of the parts of the message required to be loaded for the
// given criteria
func getRequiredParts(criteria *searchCriteria) MsgParts {
required := NONE
if len(criteria.Header) > 0 {
required |= HEADER
}
if criteria.Body != nil && len(criteria.Body) > 0 {
required |= BODY
}
if criteria.Text != nil && len(criteria.Text) > 0 {
required |= ALL
}
if criteria.WithFlags != nil && len(criteria.WithFlags) > 0 {
required |= FLAGS
}
if criteria.WithoutFlags != nil && len(criteria.WithoutFlags) > 0 {
required |= FLAGS
}
return required
}

60
worker/mbox/create.go Normal file
View file

@ -0,0 +1,60 @@
package mboxer
import (
"io"
"os"
"path/filepath"
"strings"
)
func createMailboxContainer(path string) (*mailboxContainer, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
fileInfo, err := file.Stat()
if err != nil {
return nil, err
}
mbdata := &mailboxContainer{mailboxes: make(map[string]*container)}
openMboxFile := func(path string, r io.Reader) error {
// read mbox file
messages, err := Read(r)
if err != nil {
return err
}
_, name := filepath.Split(path)
name = strings.TrimSuffix(name, ".mbox")
mbdata.mailboxes[name] = &container{filename: path, messages: messages}
return nil
}
if fileInfo.IsDir() {
files, err := filepath.Glob(filepath.Join(path, "*.mbox"))
if err != nil {
return nil, err
}
for _, file := range files {
f, err := os.Open(file)
if err != nil {
continue
}
if err := openMboxFile(file, f); err != nil {
return nil, err
}
f.Close()
}
} else {
if err := openMboxFile(path, file); err != nil {
return nil, err
}
}
return mbdata, nil
}

50
worker/mbox/io.go Normal file
View file

@ -0,0 +1,50 @@
package mboxer
import (
"io"
"io/ioutil"
"time"
"git.sr.ht/~rjarry/aerc/models"
"git.sr.ht/~rjarry/aerc/worker/lib"
"github.com/emersion/go-mbox"
)
func Read(r io.Reader) ([]lib.RawMessage, error) {
mbr := mbox.NewReader(r)
uid := uint32(0)
messages := make([]lib.RawMessage, 0)
for {
msg, err := mbr.NextMessage()
if err == io.EOF {
break
} else if err != nil {
return nil, err
}
content, err := ioutil.ReadAll(msg)
if err != nil {
return nil, err
}
messages = append(messages, &message{
uid: uid, flags: []models.Flag{models.SeenFlag}, content: content,
})
uid++
}
return messages, nil
}
func Write(w io.Writer, reader io.Reader, from string, date time.Time) error {
wc := mbox.NewWriter(w)
mw, err := wc.CreateMessage(from, time.Now())
if err != nil {
return err
}
_, err = io.Copy(mw, reader)
if err != nil {
return err
}
return wc.Close()
}

203
worker/mbox/models.go Normal file
View file

@ -0,0 +1,203 @@
package mboxer
import (
"bytes"
"fmt"
"io"
"io/ioutil"
"git.sr.ht/~rjarry/aerc/models"
"git.sr.ht/~rjarry/aerc/worker/lib"
)
type mailboxContainer struct {
mailboxes map[string]*container
}
func (md *mailboxContainer) Names() []string {
files := make([]string, 0)
for file := range md.mailboxes {
files = append(files, file)
}
return files
}
func (md *mailboxContainer) Mailbox(f string) (*container, bool) {
mb, ok := md.mailboxes[f]
return mb, ok
}
func (md *mailboxContainer) Create(file string) *container {
md.mailboxes[file] = &container{filename: file}
return md.mailboxes[file]
}
func (md *mailboxContainer) Remove(file string) error {
delete(md.mailboxes, file)
return nil
}
func (md *mailboxContainer) DirectoryInfo(file string) *models.DirectoryInfo {
var exists int
if md, ok := md.Mailbox(file); ok {
exists = len(md.Uids())
}
return &models.DirectoryInfo{
Name: file,
Flags: []string{},
ReadOnly: false,
Exists: exists,
Recent: 0,
Unseen: 0,
AccurateCounts: false,
Caps: &models.Capabilities{
Sort: true,
Thread: false,
},
}
}
func (md *mailboxContainer) Copy(dest, src string, uids []uint32) error {
srcmbox, ok := md.Mailbox(src)
if !ok {
return fmt.Errorf("source %s not found", src)
}
destmbox, ok := md.Mailbox(dest)
if !ok {
return fmt.Errorf("destination %s not found", dest)
}
for _, uidSrc := range srcmbox.Uids() {
found := false
for _, uid := range uids {
if uid == uidSrc {
found = true
break
}
}
if found {
msg, err := srcmbox.Message(uidSrc)
if err != nil {
return fmt.Errorf("could not get message with uid %d from folder %s", uidSrc, src)
}
r, err := msg.NewReader()
if err != nil {
return fmt.Errorf("could not get reader for message with uid %d", uidSrc)
}
flags, err := msg.ModelFlags()
if err != nil {
return fmt.Errorf("could not get flags for message with uid %d", uidSrc)
}
destmbox.Append(r, flags)
}
}
md.mailboxes[dest] = destmbox
return nil
}
type container struct {
filename string
messages []lib.RawMessage
}
func (f *container) Uids() []uint32 {
uids := make([]uint32, len(f.messages))
for i, m := range f.messages {
uids[i] = m.UID()
}
return uids
}
func (f *container) Message(uid uint32) (lib.RawMessage, error) {
for _, m := range f.messages {
if uid == m.UID() {
return m, nil
}
}
return &message{}, fmt.Errorf("uid [%d] not found", uid)
}
func (f *container) Delete(uids []uint32) (deleted []uint32) {
newMessages := make([]lib.RawMessage, 0)
for _, m := range f.messages {
del := false
for _, uid := range uids {
if m.UID() == uid {
del = true
break
}
}
if del {
deleted = append(deleted, m.UID())
} else {
newMessages = append(newMessages, m)
}
}
f.messages = newMessages
return
}
func (f *container) newUid() (next uint32) {
for _, m := range f.messages {
if uid := m.UID(); uid > next {
next = uid
}
}
next++
return
}
func (f *container) Append(r io.Reader, flags []models.Flag) error {
data, err := ioutil.ReadAll(r)
if err != nil {
return err
}
f.messages = append(f.messages, &message{
uid: f.newUid(),
flags: flags,
content: data,
})
return nil
}
// message implements the lib.RawMessage interface
type message struct {
uid uint32
flags []models.Flag
content []byte
}
func (m *message) NewReader() (io.ReadCloser, error) {
return ioutil.NopCloser(bytes.NewReader(m.content)), nil
}
func (m *message) ModelFlags() ([]models.Flag, error) {
return m.flags, nil
}
func (m *message) Labels() ([]string, error) {
return nil, nil
}
func (m *message) UID() uint32 {
return m.uid
}
func (m *message) SetFlag(flag models.Flag, state bool) error {
flagSet := make(map[models.Flag]bool)
flags, err := m.ModelFlags()
if err != nil {
return err
}
for _, f := range flags {
flagSet[f] = true
}
flagSet[flag] = state
newFlags := make([]models.Flag, 0)
for flag, isSet := range flagSet {
if isSet {
newFlags = append(newFlags, flag)
}
}
m.flags = newFlags
return nil
}

379
worker/mbox/worker.go Normal file
View file

@ -0,0 +1,379 @@
package mboxer
import (
"bytes"
"fmt"
"io/ioutil"
"net/url"
"os"
"path/filepath"
"sort"
"git.sr.ht/~rjarry/aerc/models"
"git.sr.ht/~rjarry/aerc/worker/handlers"
"git.sr.ht/~rjarry/aerc/worker/lib"
"git.sr.ht/~rjarry/aerc/worker/types"
gomessage "github.com/emersion/go-message"
)
func init() {
handlers.RegisterWorkerFactory("mbox", NewWorker)
}
var errUnsupported = fmt.Errorf("unsupported command")
type mboxWorker struct {
data *mailboxContainer
name string
folder *container
worker *types.Worker
}
func NewWorker(worker *types.Worker) (types.Backend, error) {
return &mboxWorker{
worker: worker,
}, nil
}
func (w *mboxWorker) handleMessage(msg types.WorkerMessage) error {
var reterr error // will be returned at the end, needed to support idle
switch msg := msg.(type) {
case *types.Unsupported:
// No-op
case *types.Configure:
u, err := url.Parse(msg.Config.Source)
if err != nil {
reterr = err
break
}
dir := u.Path
if u.Host == "~" {
home, err := os.UserHomeDir()
if err != nil {
reterr = err
break
}
dir = filepath.Join(home, u.Path)
} else {
dir = filepath.Join(u.Host, u.Path)
}
w.data, err = createMailboxContainer(dir)
if err != nil || w.data == nil {
w.data = &mailboxContainer{
mailboxes: make(map[string]*container),
}
reterr = err
break
} else {
w.worker.Logger.Printf("mbox: configured with mbox file %s", dir)
}
case *types.Connect, *types.Reconnect, *types.Disconnect:
w.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil)
case *types.ListDirectories:
dirs := w.data.Names()
sort.Strings(dirs)
for _, name := range dirs {
w.worker.PostMessage(&types.Directory{
Message: types.RespondTo(msg),
Dir: &models.Directory{
Name: name,
Attributes: nil,
},
}, nil)
w.worker.PostMessage(&types.DirectoryInfo{
Info: w.data.DirectoryInfo(name),
}, nil)
}
w.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil)
case *types.OpenDirectory:
w.name = msg.Directory
var ok bool
w.folder, ok = w.data.Mailbox(w.name)
if !ok {
w.folder = w.data.Create(w.name)
w.worker.PostMessage(&types.Done{
Message: types.RespondTo(&types.CreateDirectory{})}, nil)
}
w.worker.PostMessage(&types.DirectoryInfo{
Info: w.data.DirectoryInfo(msg.Directory),
}, nil)
w.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil)
w.worker.Logger.Printf("mbox: %s opened\n", msg.Directory)
case *types.FetchDirectoryContents:
var infos []*models.MessageInfo
for _, uid := range w.folder.Uids() {
m, err := w.folder.Message(uid)
if err != nil {
w.worker.Logger.Println("mbox: could not get message", err)
continue
}
info, err := lib.MessageInfo(m)
if err != nil {
w.worker.Logger.Println("mbox: could not get message info", err)
continue
}
infos = append(infos, info)
}
uids, err := lib.Sort(infos, msg.SortCriteria)
if err != nil {
reterr = err
break
}
if len(uids) == 0 {
reterr = fmt.Errorf("mbox: no uids in directory")
break
}
w.worker.PostMessage(&types.DirectoryContents{
Message: types.RespondTo(msg),
Uids: uids,
}, nil)
w.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil)
case *types.FetchDirectoryThreaded:
reterr = errUnsupported
case *types.CreateDirectory:
w.data.Create(msg.Directory)
w.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil)
case *types.RemoveDirectory:
if err := w.data.Remove(msg.Directory); err != nil {
reterr = err
break
}
w.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil)
case *types.FetchMessageHeaders:
for _, uid := range msg.Uids {
m, err := w.folder.Message(uid)
if err != nil {
reterr = err
break
}
msgInfo, err := lib.MessageInfo(m)
if err != nil {
reterr = err
break
} else {
w.worker.PostMessage(&types.MessageInfo{
Message: types.RespondTo(msg),
Info: msgInfo,
}, nil)
}
}
w.worker.PostMessage(
&types.Done{Message: types.RespondTo(msg)}, nil)
case *types.FetchMessageBodyPart:
m, err := w.folder.Message(msg.Uid)
if err != nil {
w.worker.Logger.Printf("could not get message %d: %v", msg.Uid, err)
reterr = err
break
}
contentReader, err := m.NewReader()
if err != nil {
reterr = fmt.Errorf("could not get message reader: %v", err)
break
}
fullMsg, err := gomessage.Read(contentReader)
if err != nil {
reterr = fmt.Errorf("could not read message: %v", err)
break
}
r, err := lib.FetchEntityPartReader(fullMsg, msg.Part)
if err != nil {
w.worker.Logger.Printf(
"could not get body part reader for message=%d, parts=%#v: %v",
msg.Uid, msg.Part, err)
reterr = err
break
}
w.worker.PostMessage(&types.MessageBodyPart{
Message: types.RespondTo(msg),
Part: &models.MessageBodyPart{
Reader: r,
Uid: msg.Uid,
},
}, nil)
case *types.FetchFullMessages:
for _, uid := range msg.Uids {
m, err := w.folder.Message(uid)
if err != nil {
w.worker.Logger.Printf("could not get message for uid %d: %v", uid, err)
continue
}
r, err := m.NewReader()
if err != nil {
w.worker.Logger.Printf("could not get message reader: %v", err)
continue
}
defer r.Close()
b, err := ioutil.ReadAll(r)
if err != nil {
w.worker.Logger.Printf("could not get message reader: %v", err)
continue
}
w.worker.PostMessage(&types.FullMessage{
Message: types.RespondTo(msg),
Content: &models.FullMessage{
Uid: uid,
Reader: bytes.NewReader(b),
},
}, nil)
}
w.worker.PostMessage(&types.Done{
Message: types.RespondTo(msg),
}, nil)
case *types.DeleteMessages:
deleted := w.folder.Delete(msg.Uids)
if len(deleted) > 0 {
w.worker.PostMessage(&types.MessagesDeleted{
Message: types.RespondTo(msg),
Uids: deleted,
}, nil)
}
w.worker.PostMessage(&types.DirectoryInfo{
Info: w.data.DirectoryInfo(w.name),
}, nil)
w.worker.PostMessage(
&types.Done{Message: types.RespondTo(msg)}, nil)
case *types.FlagMessages:
for _, uid := range msg.Uids {
m, err := w.folder.Message(uid)
if err != nil {
w.worker.Logger.Printf("could not get message: %v", err)
continue
}
if err := m.(*message).SetFlag(msg.Flag, msg.Enable); err != nil {
w.worker.Logger.Printf("could change flag %v to %v on message: %v", msg.Flag, msg.Enable, err)
continue
}
info, err := lib.MessageInfo(m)
if err != nil {
w.worker.Logger.Printf("could not get message info: %v", err)
continue
}
w.worker.PostMessage(&types.MessageInfo{
Message: types.RespondTo(msg),
Info: info,
}, nil)
}
w.worker.PostMessage(&types.DirectoryInfo{
Info: w.data.DirectoryInfo(w.name),
}, nil)
w.worker.PostMessage(
&types.Done{Message: types.RespondTo(msg)}, nil)
case *types.CopyMessages:
err := w.data.Copy(msg.Destination, w.name, msg.Uids)
if err != nil {
reterr = err
break
}
w.worker.PostMessage(&types.DirectoryInfo{
Info: w.data.DirectoryInfo(w.name),
}, nil)
w.worker.PostMessage(&types.DirectoryInfo{
Info: w.data.DirectoryInfo(msg.Destination),
}, nil)
w.worker.PostMessage(
&types.Done{Message: types.RespondTo(msg)}, nil)
case *types.SearchDirectory:
criteria, err := lib.GetSearchCriteria(msg.Argv)
if err != nil {
reterr = err
break
}
w.worker.Logger.Printf("Searching with parsed criteria: %#v", criteria)
m := make([]lib.RawMessage, 0, len(w.folder.Uids()))
for _, uid := range w.folder.Uids() {
msg, err := w.folder.Message(uid)
if err != nil {
w.worker.Logger.Println("faild to get message for uid:", uid)
continue
}
m = append(m, msg)
}
uids, err := lib.Search(m, criteria)
if err != nil {
reterr = err
break
}
w.worker.PostMessage(&types.SearchResults{
Message: types.RespondTo(msg),
Uids: uids,
}, nil)
case *types.AppendMessage:
if msg.Destination == "" {
reterr = fmt.Errorf("AppendMessage with empty destination directory")
break
}
folder, ok := w.data.Mailbox(msg.Destination)
if !ok {
folder = w.data.Create(msg.Destination)
w.worker.PostMessage(&types.Done{
Message: types.RespondTo(&types.CreateDirectory{})}, nil)
}
if err := folder.Append(msg.Reader, msg.Flags); err != nil {
reterr = err
break
} else {
w.worker.PostMessage(&types.DirectoryInfo{
Info: w.data.DirectoryInfo(msg.Destination),
}, nil)
w.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil)
}
case *types.AnsweredMessages:
reterr = errUnsupported
default:
reterr = errUnsupported
}
return reterr
}
func (w *mboxWorker) Run() {
for {
select {
case msg := <-w.worker.Actions:
msg = w.worker.ProcessAction(msg)
if err := w.handleMessage(msg); err == errUnsupported {
w.worker.PostMessage(&types.Unsupported{
Message: types.RespondTo(msg),
}, nil)
} else if err != nil {
w.worker.PostMessage(&types.Error{
Message: types.RespondTo(msg),
Error: err,
}, nil)
}
}
}
}

View file

@ -1,5 +1,9 @@
package worker
// the following workers are always enabled
import _ "git.sr.ht/~rjarry/aerc/worker/imap"
import _ "git.sr.ht/~rjarry/aerc/worker/maildir"
import (
_ "git.sr.ht/~rjarry/aerc/worker/imap"
_ "git.sr.ht/~rjarry/aerc/worker/maildir"
_ "git.sr.ht/~rjarry/aerc/worker/mbox"
)