diff --git a/README.md b/README.md index e88d613..3ec2fd5 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Why? No need to manually edit an address book, yet the cached ranking is available extremely fast. ### Features: + - scans all your emails - ranks based on both recency and frequency of addresses - collects from To, Cc, Bcc and From fields @@ -16,13 +17,15 @@ available extremely fast. - uses the most frequent non-empty display name for each email - filters common "no reply" addresses, additional filters can be added via regexes - normalizes emails to lower case -- "blazingly fast"*: crunch time for 270k emails is 7s on my machine, grepping from the output is instantaneous +- ability to add additional email addresses from a command +- "blazingly fast"\*: crunch time for 270k emails is 7s on my machine, grepping from the output is instantaneous -*: compared to original python implementation for crunching (see Behind the scenes below) and compared to using notmuch query for address completion +\*: compared to original python implementation for crunching (see Behind the scenes below) and compared to using notmuch query for address completion # Installation The easiest way to install is running: + ``` go install github.com/ferdinandyb/maildir-rank-addr@latest ``` @@ -41,6 +44,8 @@ or systemd timer). Supported flags: ``` + --addr-book-cmd string optional command to query addresses from your addressbook + --addr-book-add-unmatched if cmd is stated, determine wether to add unmatched addresses at the end of the file (true or false) --addresses strings comma separated list of your email addresses (regex possible) --config string path to config file --filters strings comma separated list of regexes to filter @@ -67,6 +72,7 @@ classification based on your explicit sends will not be possible! Uses go's `text/template` to configure output for each address (one line per address). Available keys: + ``` Address Name @@ -101,6 +107,18 @@ before the @) matches any of these strings: "nincsvalasz", ``` +**addr-book-cmd** + +Optional command to fetch email addresses and names, the output it returns must have +an email address first, followed by a tab space and and the desired name, the name +must end in a tab space or a newline for the command to work, this can be +useful for integrating with command line addressbooks such as abook or khard + +``` +abook --mutt-query "s" +khard email -p --remove-first-line +``` + **config** Path to a config file to be loaded instead of the defaults (see below). @@ -124,12 +142,12 @@ outputpath = "~/.mail/addressbook" template = "{{.Address}}\t{{.Name}}" ``` - ## Integration #### aerc Put something like this in your aerc config (using your favourite grep): + ``` address-book-cmd="ugrep -jP -m 100 --color=never %s /home/[myuser]/.cache/maildir-rank-addr/addressbook.tsv" ``` @@ -145,7 +163,6 @@ code the path without shell expansion. Ranking is actually done by first classifying and then ranking within class. - ### Classifying addresses First we go through each email found in your maildir and for each address found @@ -212,7 +229,6 @@ Please see [contribution guidelines](https://github.com/ferdinandyb/maildir-rank - [notmuch-addrlookup-c](https://github.com/aperezdc/notmuch-addrlookup-c): address lookup from notmuch - [addr-book-combine](https://jasoncarloscox.com/creations/addr-book-combine/): for combining generated addressbooks with hand currated ones, like [khard](https://github.com/lucc/khard) - # Acknowledgments Some functions for parsing email was taken from [aerc](aerc-mail.org). diff --git a/config.go b/config.go index 9d5691b..08911ca 100644 --- a/config.go +++ b/config.go @@ -2,13 +2,15 @@ package main import ( "fmt" - homedir "github.com/mitchellh/go-homedir" - "github.com/spf13/pflag" - "github.com/spf13/viper" "os" + "os/exec" "regexp" "strings" "text/template" + + homedir "github.com/mitchellh/go-homedir" + "github.com/spf13/pflag" + "github.com/spf13/viper" ) func loadConfig() Config { @@ -16,6 +18,8 @@ func loadConfig() Config { pflag.String("maildir", "", "path to maildir folder") pflag.String("outputpath", "", "path to output file") pflag.String("template", "", "output template") + pflag.String("addr-book-cmd", "", "optional command to query addresses from your addressbook") + pflag.Bool("addr-book-add-unmatched", false, "flag to determine if you want unmatched addressbook contacts to be added to the output") pflag.StringSlice("addresses", []string{}, "comma separated list of your email addresses (regex possible)") pflag.StringSlice("filters", []string{}, "comma separated list of regexes to filter") pflag.Parse() @@ -65,6 +69,14 @@ func loadConfig() Config { addresses[i] = regexp.MustCompile(filter) } templateString := viper.GetString("template") + addressbookLookupCommandString := viper.GetString("addr-book-cmd") + addressbookAddUnmatched := viper.GetBool("addr-book-add-unmatched") + var addressbookLookupCommand *exec.Cmd + if addressbookLookupCommandString != "" { + args := strings.Fields(addressbookLookupCommandString) + application, arguments := args[0], args[1:] + addressbookLookupCommand = exec.Command(application, arguments...) + } if !strings.HasSuffix(templateString, "\n") { templateString += "\n" @@ -74,11 +86,13 @@ func loadConfig() Config { panic(fmt.Errorf("bad template")) } config := Config{ - maildir: maildir, - outputpath: outputpath, - addresses: addresses, - template: tmpl, - customFilters: customFilters, + maildir: maildir, + outputpath: outputpath, + addresses: addresses, + template: tmpl, + customFilters: customFilters, + addressbookLookupCommand: addressbookLookupCommand, + addressbookAddUnmatched: addressbookAddUnmatched, } return config } diff --git a/data.go b/data.go index 80d49ec..3e01c3a 100644 --- a/data.go +++ b/data.go @@ -1,6 +1,7 @@ package main import ( + "os/exec" "regexp" "text/template" ) @@ -18,9 +19,11 @@ type AddressData struct { } type Config struct { - maildir string - outputpath string - addresses []*regexp.Regexp - template *template.Template - customFilters []*regexp.Regexp + maildir string + outputpath string + addresses []*regexp.Regexp + template *template.Template + customFilters []*regexp.Regexp + addressbookLookupCommand *exec.Cmd + addressbookAddUnmatched bool } diff --git a/maildir-rank-addr.go b/maildir-rank-addr.go index bcc1958..7a76887 100644 --- a/maildir-rank-addr.go +++ b/maildir-rank-addr.go @@ -1,10 +1,9 @@ package main -import () - func main() { config := loadConfig() - data := walkMaildir(config.maildir, config.addresses, config.customFilters) + addressbook := parseAddressbook(config.addressbookLookupCommand) + data := walkMaildir(config.maildir, config.addresses, config.customFilters, addressbook) classeddata := calculateRanks(data) - saveData(classeddata, config.outputpath, config.template) + saveData(classeddata, config.outputpath, config.template, addressbook, config.addressbookAddUnmatched) } diff --git a/output.go b/output.go index 297f2b3..c5e037e 100644 --- a/output.go +++ b/output.go @@ -13,10 +13,11 @@ func saveData( classedData map[int]map[string]AddressData, path string, tmpl *template.Template, + addressbook map[string]string, + addUnmatched bool, ) { os.MkdirAll(filepath.Dir(path), os.ModePerm) f, err := os.Create(path) - if err != nil { log.Fatal(err) } @@ -46,5 +47,14 @@ func saveData( tmpl.Execute(f, kv.Value) } } + if addUnmatched { + for ak, av := range addressbook { + aD := AddressData{} + aD.Address = ak + aD.Name = av + count++ + tmpl.Execute(f, aD) + } + } fmt.Println(count, " addresses written to ", path) } diff --git a/parseAddressbook.go b/parseAddressbook.go new file mode 100644 index 0000000..68c1235 --- /dev/null +++ b/parseAddressbook.go @@ -0,0 +1,38 @@ +package main + +import ( + "bufio" + "fmt" + "log" + "os/exec" + "strings" +) + +func parseAddressbook( + cmd *exec.Cmd, +) map[string]string { + if cmd == nil { + return nil + } + addressbook := make(map[string]string) + out, err := cmd.StdoutPipe() + if err != nil { + log.Fatal(err) + } + if err := cmd.Start(); err != nil { + log.Fatal(err) + } + scanner := bufio.NewScanner(out) + for scanner.Scan() { + slice := strings.Split(scanner.Text(), "\t") + if len(slice) < 2 { + fmt.Println("Couldn't parse ", scanner.Text()) + } else { + addressbook[strings.ToLower(slice[0])] = slice[1] + } + } + if err := cmd.Wait(); err != nil { + log.Fatal(err) + } + return addressbook +} diff --git a/parseMail.go b/parseMail.go index b0d739e..00af094 100644 --- a/parseMail.go +++ b/parseMail.go @@ -95,6 +95,7 @@ func processHeaders( retvalchan chan map[string]AddressData, addresses []*regexp.Regexp, customFilters []*regexp.Regexp, + addressbook map[string]string, ) { count := 0 retval := make(map[string]AddressData) @@ -124,12 +125,6 @@ func processHeaders( continue } for _, address := range header { - - dec := new(mime.WordDecoder) - name, err := dec.DecodeHeader(address.Name) - if err != nil { - continue - } normaddr := strings.ToLower(address.Address) if filterAddress(normaddr, customFilters) { continue @@ -140,8 +135,15 @@ func processHeaders( addresses, ) if aD, ok := retval[normaddr]; ok { - if normaddr != strings.ToLower(name) { - aD.Names = append(aD.Names, name) + if aD.Name == "" { + dec := new(mime.WordDecoder) + name, err := dec.DecodeHeader(address.Name) + if err != nil { + continue + } + if (strings.ToLower(name) != normaddr) && (strings.ToLower(name) != "") { + aD.Names = append(aD.Names, name) + } } if aD.Class < class { aD.Class = class @@ -153,11 +155,22 @@ func processHeaders( retval[normaddr] = aD } else { aD := AddressData{} + addressbookname := addressbook[normaddr] + if addressbookname == "" { + dec := new(mime.WordDecoder) + name, err := dec.DecodeHeader(address.Name) + if err != nil { + continue + } + if (strings.ToLower(name) != normaddr) && (strings.ToLower(name) != "") { + aD.Names = append(aD.Names, name) + } + } else { + aD.Name = addressbookname + delete(addressbook, normaddr) + } aD.Address = normaddr aD.Class = class - if normaddr != strings.ToLower(name) { - aD.Names = append(aD.Names, name) - } aD.ClassDate = [3]int64{0, 0, 0} aD.ClassDate[class] = time.Unix() aD.ClassCount = [3]int{0, 0, 0} @@ -174,7 +187,7 @@ func processHeaders( close(retvalchan) } -func walkMaildir(path string, addresses []*regexp.Regexp, customFilters []*regexp.Regexp) map[string]AddressData { +func walkMaildir(path string, addresses []*regexp.Regexp, customFilters []*regexp.Regexp, addressbook map[string]string) map[string]AddressData { headers := make(chan *mail.Header) messagePaths := make(chan string, 4096) @@ -189,7 +202,7 @@ func walkMaildir(path string, addresses []*regexp.Regexp, customFilters []*regex } retvalchan := make(chan map[string]AddressData) - go processHeaders(headers, retvalchan, addresses, customFilters) + go processHeaders(headers, retvalchan, addresses, customFilters, addressbook) filepath.Walk(path, func(path string, info os.FileInfo, err error) error { if err != nil { diff --git a/ranking.go b/ranking.go index 7f19d27..b8b7022 100644 --- a/ranking.go +++ b/ranking.go @@ -32,6 +32,9 @@ func getMostFrequent(names []string) string { } func normalizeAddressNames(aD AddressData) AddressData { + if aD.Name != "" { + return aD + } aD.Name = getMostFrequent(aD.Names) return aD }