/
idlebiff
  1 #!/usr/bin/tclsh
  2 
  3 # idlebiff: check for new mail in an IMAP account using IDLE extension
  4 #           the notifications arrives as soon as there is new mail
  5 # Copyright (C) 2016 Enrique D. Bosch 'presi'
  6 
  7 # idlebiff is based on the ideas of xbiff and imapbiff but the code is 
  8 # entirely rewritten
  9 
 10 #    This program is free software: you can redistribute it and/or modify
 11 #    it under the terms of the GNU General Public License as published by
 12 #    the Free Software Foundation, either version 3 of the License, or
 13 #    (at your option) any later version.
 14 #
 15 #    This program is distributed in the hope that it will be useful,
 16 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
 17 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 18 #    GNU General Public License for more details.
 19 #
 20 #    You should have received a copy of the GNU General Public License
 21 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
 22 
 23 package require Tk
 24 package require crc32
 25 package require tkpng
 26 package require png
 27 package require tooltip
 28 
 29 namespace path ::tcl::mathop
 30 proc or args { foreach x $args { if { $x } { return 1 } }; return 0 }
 31 proc and args { foreach x $args { if { $x } continue  { return 0 } }; return 1 }
 32 
 33 proc setappend { var args } { upvar 1 $var v; set v [ join $args {} ] }
 34 
 35 proc check_conf_and_set_default { opt default } \
 36 {
 37   variable ::shared::conf
 38 
 39   if {![ info exists conf($opt) ]} { set conf($opt) $default }
 40 }
 41 
 42 namespace eval static \
 43 {
 44   variable ntag 1
 45   variable ccsp 0
 46   variable pmsgs 0
 47   variable search_resp 0
 48   array set ccsu {}
 49 }
 50 
 51 namespace eval shared \
 52 {
 53   variable idle 0
 54   array set cap \
 55   {
 56     logindisabled 0
 57     logindisabled_chk 0
 58     chk 0
 59     idle 0
 60     esearch 0
 61   }
 62   variable search
 63   variable rexp_chk
 64   variable rexpsearch
 65   variable sock
 66   variable conf
 67   array set conf {}
 68   variable tevent
 69   array set tevent {}
 70   variable teidi 0
 71   variable teidf 0
 72   variable ka 0
 73   variable img_up
 74   variable img_down
 75   variable img_chk
 76   variable new_messages 0
 77   variable tag
 78 }
 79 
 80 proc define_pixmaps {} \
 81 {
 82   variable ::shared::img_up
 83   variable ::shared::img_down
 84   variable ::shared::img_chk
 85   variable ::shared::conf
 86 
 87   set img_up [ image create photo -format png -file $conf(png_up) ]
 88   set img_down [ image create photo -format png -file $conf(png_down) ]
 89   set img_chk [ image create photo -format png -file $conf(png_chk) ]
 90 }
 91 
 92 proc icon {} \
 93 {
 94   variable ::shared::img_up
 95   variable ::shared::img_down
 96   variable ::shared::img_chk
 97   variable ::shared::conf
 98 
 99   set pnginfo [ ::png::imageInfo $conf(png_up) ]
100   set png_up_w [ dict get $pnginfo width ]
101   set png_up_h [ dict get $pnginfo height ]
102 
103   set pnginfo [ ::png::imageInfo $conf(png_down) ]
104   set png_down_w [ dict get $pnginfo width ]
105   set png_down_h [ dict get $pnginfo height ]
106 
107   set pnginfo [ ::png::imageInfo $conf(png_chk) ]
108   set png_chk_w [ dict get $pnginfo width ]
109   set png_chk_h [ dict get $pnginfo height ]
110 
111   set size_not_same [ or [ != $png_up_w $png_down_w ] [ != $png_down_w $png_chk_w ] [ != $png_up_h $png_down_h ] [ != $png_down_h $png_chk_h ] ]
112   if { $size_not_same } { emit_error size }
113 
114   wm title . idlebiff
115   wm iconname . idlebiff
116   append geom [ + $png_up_w 10 ] x [ + $png_up_h 10 ]
117   if {[ and $conf(pos_x) $conf(pos_y) ]} { append geom + $conf(pos_x) + $conf(pos_y) }
118   wm geometry . $geom
119   wm resizable . 0 0
120   wm overrideredirect . $conf(nodecor)
121 
122   frame .frame
123   pack .frame -expand 1 -fill both
124   canvas .frame.canvas -width $png_up_w -height $png_up_h -bd 10 -relief sunken
125   pack .frame.canvas -expand 1 -fill both
126 
127   define_pixmaps
128 
129   button .frame.canvas.button -image $img_chk -cursor left_ptr
130   .frame.canvas create window 0 0 -window .frame.canvas.button -anchor nw 
131   pack .frame.canvas.button -expand 1 -fill both
132 
133   bind .frame.canvas.button <Button-1> force_down
134   bind .frame.canvas.button <Button-2> { cleanup $con }
135   bind .frame.canvas.button <Button-3> { idle_cycle $con 1 }
136 }
137 
138 proc get_password bad \
139 {
140   variable ::shared::conf
141   upvar #0 pass pass
142 
143   if { $bad } { set textbad "WRONG PASSWORD\n" } \
144   else { set textbad {} }
145   if { !$conf(tls) } { set nv "\nWARNING: INSECURE CONNECTION" }
146   
147   toplevel .tl
148   wm title .tl {idlebiff: password}
149   message .tl.msg -bg black -fg yellow -width 400 -justify center \
150           -text "${textbad}Enter password for\n$conf(user) at $conf(host)"
151   pack .tl.msg -side top -fill both
152   entry .tl.entry -relief sunken -bg grey -fg black -width 30 -show *
153   pack .tl.entry -side top
154   button .tl.bok -text OK -padx 20 -command { set pass [ .tl.entry get ]; destroy .tl }
155   pack .tl.bok -side right
156   bind .tl.entry <Return> { .tl.bok invoke }
157   button .tl.bc -text Cancel -command { cleanup $con } -padx 20
158   pack .tl.bc -side left
159   focus .tl.entry
160   vwait pass
161   set p $pass
162   unset pass
163   return $p
164 }
165 
166 proc update_img img \
167 {
168   variable ::shared::conf
169   variable ::shared::new_messages
170 
171   .frame.canvas.button configure -image $img
172   if { $conf(tip) } { tooltip::tooltip .frame.canvas $new_messages }
173 }
174 
175 proc read_conf {} \
176 {
177   upvar #0 argv arg argc argn
178   variable ::shared::conf
179 
180   if {[ == $argn 1 ]} { set config $arg } else { set config "$::env(HOME)/.idlebiffrc" }
181   set cf [ open $config ]
182   while {[ >= [ gets $cf lin ] 0 ]} \
183   {
184     if {[ == [ string index [ string trim $lin ] 0 ] # ]} continue
185     set lconf [ split $lin = ]
186     set val [ join [ lassign $lconf key ] = ]
187     set conf([ string trim $key]) [ string trim $val ]
188   }
189 }
190 
191 proc set_params {} \
192 {
193   variable ::shared::rexp_chk
194 
195   check_conf_and_set_default criteria UNSEEN
196   check_conf_and_set_default nodecor 1
197   check_conf_and_set_default tip 0
198   check_conf_and_set_default tls 0
199   check_conf_and_set_default tls_validate_cert 1
200   check_conf_and_set_default initial_noop 0
201   check_conf_and_set_default imap_trace 0
202   set rexp_chk {(?: |\[)capability }
203 }
204 
205 proc set_search_params {} \
206 {
207   variable ::shared::cap
208   variable ::shared::search
209   variable ::shared::rexpsearch
210   variable ::shared::conf
211 
212   if { $cap(esearch) } \
213   {
214     set search "SEARCH RETURN (COUNT) $conf(criteria)"
215     set rexpsearch {^\* esearch \(tag [^)]*\) count (.*)$}
216   } \
217   else \
218   {
219     set search "SEARCH $conf(criteria)"
220     set rexpsearch {^\* search((?: .*|$))}
221   }
222 }
223 
224 proc emit_error args { error $args }
225 
226 proc set_timeout {} \
227 {
228   variable ::shared::conf
229   variable ::shared::tevent
230   variable ::shared::teidf
231 
232   set tevent($teidf) [ after ${conf(timeout)}000 \
233                        {
234                          update_img $::shared::img_chk
235                          cancel_timeout
236                          close $con
237                          init
238                        } \ 
239                      ]
240   incr teidf  
241 }
242 
243 proc cancel_timeout {} \
244 {
245   variable ::shared::tevent
246   variable ::shared::teidi
247 
248   if {[ info exists tevent($teidi) ]} \
249   {
250     after cancel $tevent($teidi)
251     unset tevent($teidi)
252     incr teidi
253   }
254 }
255 
256 proc exec_terse { com args } \
257 {
258   if {[ catch { set res [ eval "$com [ join $args]" ] } ]} \
259   {
260     set terse [ split $::errorInfo "\n" ]
261     puts [ join [ lrange $terse 0 0 ] ]
262     exit 1
263   }
264   return $res
265 }
266 
267 proc recon con \
268 {
269   variable ::shared::ka
270   variable ::shared::idle
271   variable ::shared::img_chk
272 
273   after cancel $ka
274   update_img $img_chk
275   set idle 0
276   catch { close $con }
277   after 3000 init
278 }
279 
280 proc imap_command { con com } \
281 {
282   variable ::shared::conf
283 
284   if { $conf(imap_trace) } \
285   { if {[ regexp {.*LOGIN.*} $com ]} { puts "C: [string range $com 0 10]" } else { puts "C: $com" } }
286 
287   set r [ catch { puts $con $com } ]
288   if {[ eq $com LOGOUT ]} return
289   if { $r } \
290   {
291     recon $con
292     return
293   }
294   set_timeout
295 }
296 
297 proc imap_command_tagged { con com { prefix A } } \
298 {
299   variable ::static::ntag
300   variable ::shared::tag
301 
302   if {[ == $ntag 10000 ]} { set ntag 1 }
303   setappend tag $prefix [ format %04d $ntag ]
304   append tcom $tag { } $com
305   imap_command $con $tcom
306   incr ntag
307 }
308 
309 proc ok_tagged { linea { tag {A\d+} } } \
310 {
311   append rexp {^} $tag { ok.*$}
312   return [ regexp -lineanchor -nocase $rexp $linea ]
313 }
314 
315 proc ok_untagged linea \
316 {
317   return [ regexp -lineanchor -nocase {^\* ok.*$} $linea ]
318 }
319 
320 proc no_tagged linea \
321 {
322   return [ regexp -lineanchor -nocase {^A\d+ no.*$} $linea ]
323 }
324 
325 proc login { con retr } \
326 {
327   variable ::shared::conf
328   variable ::shared::cap
329 
330   if { $cap(logindisabled) } { emit_error logindisabled }
331 
332   if { $retr } { unset conf(pass) }
333   if { ![ info exists conf(pass) ] } { set conf(pass) [ get_password $retr ] }
334 
335   imap_command_tagged $con "LOGIN $conf(user) $conf(pass)"
336 }
337 
338 proc logout con \
339 {
340   variable ::shared::conf
341 
342   fileevent $con readable { parse_logout $con }
343   after ${conf(timeout_logout)}000 \
344   {
345     close $con
346     exit 0
347   }
348   imap_command_tagged $con LOGOUT
349 }
350 
351 proc cleanup con \
352 {
353   variable ::shared::ka
354   variable ::shared::img_chk
355   variable ::shared::idle
356 
357   update_img $img_chk
358   after cancel $ka
359   if { $idle } { exit_idle $con }
360   logout $con
361 }
362 
363 proc force_down {} \
364 {
365   variable ::shared::img_down
366 
367   update_img $img_down
368 }
369 
370 proc enter_idle con \
371 {
372   imap_command_tagged $con IDLE
373 }
374 
375 proc exit_idle con \
376 {
377   variable ::shared::idle
378 
379   set idle 0
380   imap_command $con DONE
381 }
382 
383 proc enter_mailbox con \
384 {
385   variable ::shared::conf
386 
387   imap_command_tagged $con "EXAMINE $conf(mailbox)"
388 }
389 
390 proc exit_mailbox con \
391 {
392   imap_command_tagged $con CLOSE
393 }
394 
395 proc do_search con \
396 {
397   variable ::shared::search
398   variable ::shared::img_chk
399 
400   update_img $img_chk
401   imap_command_tagged $con $search
402 }
403 
404 proc check_cap { linea args } \
405 {
406   variable ::shared::cap
407   variable ::shared::rexp_chk
408 
409   if {[ regexp -nocase -lineanchor $rexp_chk $linea ]} \
410   {
411     if {[ eq $args logindisabled ]} { set cap(logindisabled_chk) 1 } \
412     else { set cap(chk) 1 }
413     foreach capname $args \
414     {
415       if { !$cap($capname) } \
416       {
417         set rexp_cap " ${capname}(?: |]|$)"
418         set cap($capname) [ regexp -nocase -lineanchor $rexp_cap $linea ]
419       }
420     }
421   }
422 }
423 
424 proc idle_cycle { con check } \
425 {
426   variable ::shared::conf
427   variable ::shared::ka
428 
429   after cancel $ka
430   exit_idle $con
431   if { $check } { do_search $con } \
432   else { enter_idle $con }
433   set ka [ after ${conf(keepalive)}000 { idle_cycle $con 0 } ]
434 }
435 
436 proc init_idle con \
437 {
438   variable ::shared::conf
439   variable ::shared::ka
440   variable ::shared::cap
441 
442   if { !$cap(idle) } { emit_error noidle }
443   fileevent $con readable { parse_idle_check $con }
444   enter_mailbox $con
445   set_search_params
446   do_search $con
447   set ka [ after ${conf(keepalive)}000 { idle_cycle $con 0 } ]
448 }
449 
450 proc parse_greeting con \
451 {
452   variable ::shared::cap
453 
454   set linea [ read_line $con ]
455   check_cap $linea logindisabled
456   if {[ ok_untagged $linea ]} \
457   {
458     if { $cap(logindisabled_chk) } \
459     {
460       fileevent $con readable { parse_login $con }
461       login $con 0
462     } \
463     else \
464     {
465       fileevent $con readable { parse_logindisabled $con }
466       imap_command_tagged $con CAPABILITY
467     }
468   }
469 }
470 
471 proc parse_logindisabled con \
472 {
473   variable ::shared::cap
474 
475   set linea [ read_line $con ]
476   check_cap $linea logindisabled
477   if {[ ok_tagged $linea ]} \
478   {
479     if { $cap(logindisabled_chk) } \
480     {
481       fileevent $con readable { parse_login $con }
482       login $con 0
483     }
484   }
485 }
486 
487 proc parse_login con \
488 {
489   variable ::shared::cap
490 
491   set linea [ read_line $con ]
492   check_cap $linea idle esearch
493   if {[ ok_tagged $linea ]} \
494   {
495     if { $cap(chk) } { init_idle $con } \
496     else \
497     {
498       fileevent $con readable { parse_cap $con }
499       imap_command_tagged $con CAPABILITY
500     }
501   } \
502   elseif {[ no_tagged $linea ]} { login $con 1 }
503 }
504 
505 proc parse_cap con \
506 {
507   set linea [ read_line $con ]
508   check_cap $linea idle esearch
509   if {[ ok_tagged $linea ]} { init_idle $con }
510 }
511 
512 proc parse_idle_check con \
513 {
514   variable ::shared::idle
515   variable ::shared::ka
516   variable ::shared::conf
517 
518   if {[ catch { set linea [ read_line $con ] } ]} return
519   set prim [ string range $linea 0 0 ]
520   if {[ eq $prim + ]} { set idle 1 } \
521   elseif { $idle } \
522   {
523     if {[ eq $prim * ]} \
524     {
525       if {[ ok_untagged $linea ]} \
526       {
527         after cancel $ka
528         set ka [ after ${conf(keepalive)}000 { idle_cycle $con 0 } ]
529       } \
530       else { idle_cycle $con 1 } \
531     } \
532   } \
533   else { parse_search $con $linea }
534 }
535 
536 proc parse_search { con linea } \
537 {
538   variable ::shared::conf
539   variable ::shared::img_up
540   variable ::shared::img_down
541   variable ::shared::new_messages
542   variable ::shared::cap
543   variable ::shared::rexpsearch
544   variable ::static::pmsgs
545   variable ::static::search_resp
546 
547   if {[ regexp -lineanchor -nocase $rexpsearch $linea -> res ]} \
548   {
549     if { $cap(esearch) } { set pmsgs $res } else { incr pmsgs [ llength $res ] }
550     set search_resp 1
551   }
552   if {[ and $search_resp [ ok_tagged $linea ] ]} \
553   {
554     enter_idle $con
555     set new_messages $pmsgs
556     set pmsgs 0
557     set search_resp 0
558     if {[ > $new_messages 0 ]} \
559          { set current $img_up } \
560     else { set current $img_down }
561     update_img $current
562   }
563   if {[ no_tagged $linea ]} { emit_error search }
564 }
565 
566 proc parse_logout con \
567 {
568   variable ::shared::conf
569   variable ::shared::tag
570 
571   if {[ catch { gets $con linea } ]} return
572   if { $conf(imap_trace) } { puts "S: $linea" }
573   if {[ ok_tagged $linea $tag ]} \
574   {
575     close $con
576     exit 0
577   }
578 }
579 
580 proc read_line con \
581 {
582   variable ::shared::conf
583 
584   cancel_timeout
585   if {[ catch { set r [ gets $con line ] } ]} \
586   {
587     recon $con
588     return -code error
589   }
590   if {[ regexp -lineanchor -nocase {^\* bye(?: .*|$)} $line ]} \
591   {
592     recon $con
593     return -code error
594   }
595   if {[ and [ < $r 1 ] [ eof $con ] ]} \
596   {
597     recon $con
598     return -code error
599   } \
600   else \
601   {
602     if { $conf(imap_trace) } { puts "S: $line" }
603 
604     return $line
605   }
606 }
607 
608 proc parse_cert_subject sub \
609 {
610   set cst [ split [ string map { , = / = } $sub ] = ]
611   foreach v $cst { lappend cstp [ string trim $v ] }
612   return $cstp
613 }
614 
615 proc tls_cb { type channel depth cert status error } \
616 {
617   variable ::shared::conf
618   variable ::static::ccsu
619   variable ::static::ccsp
620 
621   array set ca $cert
622   if {[ info exists conf(tls_ca_subject) ]} \
623   {
624     if {[ and [ ne $conf(tls_ca_subject) {} ] [ eq $type verify ] [ eq $ca(subject) $ca(issuer) ] ]} \
625     {
626       array set csu [ parse_cert_subject $ca(subject) ]
627       if { !$ccsp } \
628       {
629         array set ccsu [ parse_cert_subject $conf(tls_ca_subject) ]
630         set ccsp 1
631       }
632       foreach i [ array names ccsu ] \
633       {
634         if { ![ info exists csu($i) ] } { emit_error cert }
635         if {[ ne $csu($i) $ccsu($i) ]} { emit_error cert }
636       }
637     }
638   }
639 }
640 
641 proc init_tls {} \
642 {
643   variable ::shared::conf
644   variable ::shared::sock
645 
646   if { $conf(tls) } \
647   {
648     set tlsv [ package require tls ]
649     if {[ >= $tlsv 1.6.4 ]} { set tls1x {-tls1.1 1 -tls1.2 1} } \
650     else { set tls1x {} }
651     if {[ info exists conf(sni) ]} { set sni "-servername $conf(sni)" } \
652     else { set sni {} }
653     set sock "::tls::socket -ssl2 0 -ssl3 0 -tls1 1 $tls1x \
654       -cafile   \"$conf(tls_ca_file)\" \
655       -cadir    \"$conf(tls_ca_dir)\" \
656       -certfile \"$conf(tls_client_cert)\" \
657       -keyfile  \"$conf(tls_client_key)\" \
658       -request  \"$conf(tls_validate_cert)\" \
659       -require  \"$conf(tls_validate_cert)\" \
660       -cipher   \"$conf(tls_ciphers)\" \
661       $sni \
662       -command  tls_cb"
663   } \
664   else { set sock socket }
665 }
666 
667 proc init {} \
668 {
669   upvar #0 con con
670   variable ::shared::conf
671   variable ::shared::sock
672 
673   set con [ exec_terse $sock -async $conf(host) $conf(port) ]
674   fconfigure $con -blocking 0 -buffering line -translation {crlf crlf}
675   fileevent $con readable { parse_greeting $con }
676   if { $conf(initial_noop) } { imap_command_tagged $con NOOP N }
677 }
678 
679 read_conf
680 set_params
681 icon
682 init_tls
683 init
684 
685 vwait forever