IMAP new mail notification utility with IDLE support and Notification Center (OS X) and DBus (others) notifications
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!