Home brewed mutt contact query script

2016年02月26日

In my last post, I mentioned the usage of customized query_command to look up contact email address. Some might wonder why I would want to do this despite there are many mature tools, such as abook and lbdb, available.

The reason is simple. I just do not like to install extra any extra packages for mutt contact management. Having a few scripts safely backed-up to github and providing the same functionality, is much easier to maintain.

lbdb has a script to be used in mutt sendmail command; it collects recipients’ addresses from email header, and puts them in a database, which can later be queried with predefined query_command. This was the way I used the most to manage contacts, and all the ones I care about are saved automatically when the first time an email was sent out to them.

It turns out this is not at all difficult to be rolled out in ruby.

First of all, the contact_query script. I have added ldap search functionality to it as well.

#!/usr/bin/env ruby
# coding: utf-8
#Description:
#   remember mutt outgoing recipients as well as from whom I have read
#   their emails
require 'mail'
require 'yaml'

MUTT_DIR = "#{ENV['HOME']}/.mutt"
CONTACTS_FILE = File.join(MUTT_DIR, "contacts.yml")
LDAP_ENV_FILE = File.join(MUTT_DIR, ".env")

require 'dotenv'  rescue abort "Please install net-ldap gem!"
Dotenv.load!(LDAP_ENV_FILE)

module Contacts
  def self.save_mail_contacts(mail_body, fields=[ :from ], with_timestamp = true )
    a = LocalContacts.new
    a.from_mail(mail_body, options[:fetch], options[:time]).save
  end

  def self.query(filter)
    agents = constants.select{|c| Class === const_get(c)}.map{|c| const_get(c) }
    agents.map{|c| c.new.query(filter) }.inject(&:+)
  end

  def self.query_print(filter)
    res = query(filter)
    puts
    res.each do |email, name, detail|
      puts "#{email}\t#{name || ' '}\t#{detail || ' '}"
    end
  end

  do_ldap = false
  if ENV['LDAP_BASEDN']
    begin
      require 'net/ldap'    # ruby-net-ldap gem needed
      do_ldap = true
    rescue
      nil
    end
  end

  class LDAPContacts
    def initialize
      @ldap = Net::LDAP.new :host => ENV['LDAP_SERVER'],
                            :port => ENV['LDAP_SERVER_PORT'],
                            :base => ENV['LDAP_BASEDN'],
                            :encryption => :simple_tls,
                            :auth => { :method => :simple,
                                       :username => ENV['LDAP_BINDDN'],
                                       :password => ENV['LDAP_PASSWORD'] }
    end

    def query(filter)
      f =  Net::LDAP::Filter.eq( "cn", "*#{filter}*" ) | Net::LDAP::Filter.eq( "mail", "*#{filter}*" )
      extra_fields = ENV['LDAP_EXTRA_FIELDS'] ?
        ENV['LDAP_EXTRA_FIELDS'].downcase.split.map(&:to_sym) :
        [ :telephonenumber ]
      fields = %w( cn  mail ) + extra_fields
      res = @ldap.search( filter: f, attributes: fields )
      res.map{|entry|
        [ entry.mail.first,     # mail
          entry.cn.first,       # name
          extra_fields.map{|i| entry[i].first.to_s }.compact.join(" | ") ]    # extra info
      }
    end
  end   if do_ldap

  class LocalContacts
    def initialize(file = CONTACTS_FILE)
      @file = file
      @contacts = File.file?(@file) ? YAML.load_file(@file) : {}
    end

    def from_mail(mail_text, fields = [:from], with_timestamp = true)
      m = Mail.new(mail_text)
      new_contacts = fields.collect{|f| m[f]}.compact.collect{|f|
        [f.addresses, f.display_names].transpose
      }.inject(&:+).uniq

      new_contacts = Hash[new_contacts.collect{|a,n| [a, {:name => n}.delete_if{|k,v| v.nil? }] } ]

      new_contacts.each{|_,v| v[:last] = Time.now}  if with_timestamp

      new_contacts.each_key do |a|
        @contacts[a] ? contacts[a].merge(new_contacts[a]) : ( contacts[a] = new_contacts[a] )
      end

      self
    end

    def save
      open(@file, 'w'){|f| f.puts @file.to_yaml}
    end

    def query(filter)
      @contacts.select{|k, v| k + v[:name].to_s =~ /#{filter}/i }.     # select matching results
          sort{|a,b| a.last[:last].to_s <=> b.last[:last].to_s }.      # order by last contact date, desceding
          map{|a,h| [a, h[:name], h[:last]] }
    end

  end
end

if __FILE__ == $0
  require 'optparse'
  options = {}
  OptionParser.new do |opts|
    opts.banner = "Usage: #{$0} [options] [STRING]"
    options[:fetch] = nil
    opts.on('-f','--fetch FIELDS','Fetch addresses from email, FIELDS should be comma separated') { |fields|
      options[:fetch] = fields.downcase.split(',').map(&:to_sym)
    }
    opts.on('-t','--time',
            'Toggle if timestamps should be added to record',
            'Records with timestamps always stand before those without.'
           ) { options[:time] = true }
    opts.on('-p','--print',
            'Toggle printing of the original message',
            'Useful when this script is used as mutt :display_filter '
           ) { options[:print] = true }
  end.parse!
  if options[:fetch]  # fetch
    mail_body = ARGF.read
    Contacts.save_mail_contacts(mail_body, options[:fetch], options[:time])
    puts mail_body  if options[:print]
  else  # query
    Contacts.query_print(ARGV.first)
  end
end

Then the do_sendmail script. Here I also added an ugly attachment checking.

#!/bin/bash
export PATH=~/.mutt:$PATH

## Save msg in file to re-use it for multiple tests.
t=`mktemp -t mutt_message.XXXXXX` || exit 3
cat > "$t"

# save email addresses in contacts.yml
contact_query -f to,cc,bcc -t "$t"

## Attachment keywords that the message body will be searched for:
KEYWORDS='attach|patch|附件|文件'

## Define test for multipart message.
function multipart() {
    grep -q '^Content-Type: multipart' "$t"
}

## Define test for keyword search.
function word-attach() {
    grep -v '^>' "$t" | grep -E -i -q "$KEYWORDS"
}

## define a dialog to confirm sending
function ask() {
    # # try to get stdin/stdout/stderr from parent process
    pid=$$
    tty=$(ps -o tty= -p $pid)
    while ! [[ $tty = *t* ]]; do  # at least contain a *t* for pts/tty/etc
        pid=$(ps -o ppid= -p $pid)
        tty=$(ps -o tty= -p $pid)
    done
    TTY=/dev/$tty
    # ps -p $pid |grep nonono
    dialog --defaultno \
        --title "Attachment Missing" \
        --yesno "There is no attachment found.\nDo you still want to send anyway?" 0 0 < $TTY > $TTY
}

# FINAL DECISION:
if multipart || ! word-attach || ask ; then
    exit_status=$?
    # send email
    "$@" < "$t"
else
    echo "No file was attached but a search of the message text suggests there should be one."
    exit_status=1
fi

rm -f "$t"

exit $exit_status

Here this line acts like lbdb-fetchaddr and collects recipients’ email addresses.

contact_query -f to,cc,bcc -t "$t"

Time to enable these two scripts in .muttrc

set query_command = "~/.mutt/contact_query"
set sendmail      = "~/.mutt/do_sendmail msmtp"

Now in mutt, any recipients in an outgoing message will be saved in ~/.mutt/contacts.yml, which is friendlier than lbdb’s binary database format. And pressing Ctrl-t will complete email addresses according to saved addresses.

Update: now I split query script in to smaller ones in a folder named ~/.mutt/contacts and use run-parts to call them like advised here. You can find updated code in my github dotfiles repository.