IMAP new mail notification utility with IDLE support and Notification Center (OS X) and DBus (others) notifications
at master 270 lines 7.7 kB view raw
1#!/usr/bin/env ruby 2# 3# IMAP biff with notifications supporting multiple accounts and IMAP IDLE. 4# 5# To configure, create a YAML file at ~/.imapbiffrc file like so: 6# 7# --- 8# :accounts: 9# - :hostname: mail.example.com 10# :username: user@example.com 11# - :hostname: mail2.example.com 12# :username: user2@example.com 13# :label: "[user2 mail] " 14# 15# For OS X, `brew install terminal-notifier` to install terminal notifier 16# command line application. If your passwords are in keychain, you can avoid 17# having them in plaintext in your ~/.imapbiffrc file, and they will be fetched 18# from `security` on startup. You can add them to your keychain with: 19# 20# $ security add-internet-password -a <username> -s <hostname> -w <password> 21# 22 23# 24# Copyright (c) 2015 joshua stein <jcs@jcs.org> 25# 26# Redistribution and use in source and binary forms, with or without 27# modification, are permitted provided that the following conditions 28# are met: 29# 30# 1. Redistributions of source code must retain the above copyright 31# notice, this list of conditions and the following disclaimer. 32# 2. Redistributions in binary form must reproduce the above copyright 33# notice, this list of conditions and the following disclaimer in the 34# documentation and/or other materials provided with the distribution. 35# 3. The name of the author may not be used to endorse or promote products 36# derived from this software without specific prior written permission. 37# 38# THIS SOFTWARE IS PROVIDED BY THE AUTHOR "AS IS" AND ANY EXPRESS OR 39# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES 40# OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 41# IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, 42# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT 43# NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 44# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 45# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 46# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF 47# THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 48# 49 50require "net/imap" 51require "open3" 52require "yaml" 53 54class Notifier 55 def self.notify(options = {}) 56 options = { 57 :title => "imapbiff", 58 :message => options[:message], 59 }.merge(options) 60 61 if options[:label] 62 options[:title] = "#{options[:label]}#{options[:title]}" 63 options.delete(:label) 64 end 65 66 if RUBY_PLATFORM.match(/darwin/) 67 options[:sender] = "com.apple.Mail" 68 options.each do |k,v| 69 if v[0] == "\"" || v[0] == "[" 70 options[k] = "\\" + v 71 end 72 end 73 74 args = [ 75 "/Applications/terminal-notifier.app/Contents/MacOS/terminal-notifier", 76 ] + options.map{|k,v| [ "-#{k}", v ] }.flatten 77 78 system(*args) 79 else 80 puts "need to notify: [#{title}] [#{message}]" 81 end 82 end 83end 84 85class IMAPConnection 86 attr_reader :hostname, :username, :password, :mailbox 87 attr_accessor :label 88 89 def initialize(hostname, username, password, mailbox = "inbox") 90 @hostname = hostname 91 @username = username 92 @password = password 93 @mailbox = mailbox 94 95 @imap = nil 96 end 97 98 def imap 99 if @imap 100 return @imap 101 end 102 103 @imap = Net::IMAP.new(self.hostname, "imaps", ssl = true) 104 @imap.authenticate("LOGIN", self.username, self.password) 105 106 self.notify({ :message => "Connected to #{self.hostname} as " << 107 "#{self.username}" }) 108 109 @imap 110 end 111 112 def idle_loop 113 while true do 114 begin 115 self.imap.examine(self.mailbox) 116 117 while true do 118 unseen = nil 119 120 imap.idle do |resp| 121 if resp.is_a?(Net::IMAP::UntaggedResponse) && resp.name == "EXISTS" 122 unseen = resp.data 123 imap.idle_done 124 end 125 end 126 127 if !unseen 128 next 129 end 130 131 flags = imap.fetch(unseen, "FLAGS").first.attr.values.first 132 if flags.include?(:Seen) 133 next 134 end 135 136 imap.examine(self.mailbox) 137 138 attrs = { :body => "Unable to read message" } 139 140 begin 141 [ :from, :subject ].each do |f| 142 attrs[f] = imap.fetch(unseen, 143 "BODY.PEEK[HEADER.FIELDS (#{f.to_s.upcase})]"). 144 first.attr.values.first.strip.gsub(/^[^:]+: ?/, "") 145 146 if m = attrs[f].to_s.match(/^=\?([^\?]+)\?([QB])\?(.+)\?=$/) 147 if m[2].downcase == "q" 148 attrs[f] = m[3].unpack("M*").first 149 elsif m[2].downcase == "b" 150 attrs[f] = m[3].unpack("m*").first 151 end 152 end 153 end 154 155 encoding = nil 156 textpart = 0 157 158 struct = imap.fetch(unseen, "BODYSTRUCTURE"). 159 first.attr.values.first 160 case struct.class.to_s 161 when "Net::IMAP::BodyTypeMultipart" 162 struct.parts.each_with_index do |part,x| 163 if part.media_type.downcase == "text" && 164 part.subtype.downcase == "plain" 165 textpart = "1.#{x + 1}" 166 encoding = part.encoding 167 break 168 end 169 end 170 171 when "Net::IMAP::BodyTypeText" 172 if struct.subtype.downcase == "plain" 173 textpart = 1 174 encoding = struct.encoding 175 end 176 end 177 178 if textpart == 0 179 attrs[:body] = "HTML message" 180 else 181 attrs[:body] = imap.fetch(unseen, 182 "BODY.PEEK[#{textpart}]<0.200>").first.attr.values.first 183 184 if encoding.to_s.match(/quoted/i) 185 attrs[:body] = attrs[:body].unpack("M*").first 186 elsif encoding.to_s.match(/base64/i) 187 attrs[:body] = attrs[:body].unpack("m*").first 188 end 189 end 190 191 rescue => e 192 puts e.inspect 193 end 194 195 self.notify({ :title => attrs[:from], :subtitle => 196 attrs[:subject], :message => attrs[:body] }) 197 end 198 199 rescue IOError => e 200 @imap = nil 201 sleep 5 202 203 rescue StandardError => e 204 self.notify({ :title => "[#{self.hostname}] imapbiff error: " << 205 "#{e.class}", :message => e.message }) 206 sleep 5 207 end 208 end 209 end 210 211 def notify(options) 212 if self.label.to_s != "" 213 options[:label] = self.label 214 end 215 216 Notifier.notify(options) 217 end 218end 219 220class IMAPBiff 221 attr_reader :connections 222 223 def initialize(config) 224 @connections = [] 225 226 config[:accounts].each do |acct| 227 if !acct[:password] 228 if RUBY_PLATFORM.match(/darwin/) 229 IO.popen([ "/usr/bin/security", "find-internet-password", "-g", 230 "-a", acct[:username], "-s", acct[:hostname] ], 231 :err => [ :child, :out ]) do |sec| 232 while sec && !sec.eof? 233 if m = sec.gets.match(/^password: "(.+)"$/) 234 acct[:password] = m[1] 235 end 236 end 237 end 238 end 239 end 240 241 if acct[:password].to_s == "" 242 Notifier.notify({ :message => "failed to initialize " << 243 "#{acct[:username]}@#{acct[:hostname]}: no password found" }) 244 exit 1 245 end 246 247 c = IMAPConnection.new(acct[:hostname], acct[:username], acct[:password]) 248 if acct[:label] 249 c.label = acct[:label] 250 end 251 252 @connections.push c 253 end 254 end 255 256 def run! 257 Thread.abort_on_exception = true 258 259 threads = @connections.map{|c| 260 Thread.new(c) { 261 connection = c 262 c.idle_loop 263 } 264 } 265 266 threads.each{|th| th.join } 267 end 268end 269 270IMAPBiff.new(YAML.load_file("#{ENV["HOME"]}/.imapbiffrc")).run!