#!/usr/bin/tclsh
# idlebiff: check for new mail in an IMAP account using IDLE extension
# the notifications arrives as soon as there is new mail
# Copyright (C) 2016 Enrique D. Bosch 'presi'
# idlebiff is based on the ideas of xbiff and imapbiff but the code is
# entirely rewritten
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
package require Tk
package require crc32
package require tkpng
package require png
package require tooltip
namespace path ::tcl::mathop
proc or args { foreach x $args { if { $x } { return 1 } }; return 0 }
proc and args { foreach x $args { if { $x } continue { return 0 } }; return 1 }
proc setappend { var args } { upvar 1 $var v; set v [ join $args {} ] }
proc check_conf_and_set_default { opt default } \
{
variable ::shared::conf
if {![ info exists conf($opt) ]} { set conf($opt) $default }
}
namespace eval static \
{
variable ntag 1
variable ccsp 0
variable pmsgs 0
variable search_resp 0
array set ccsu {}
}
namespace eval shared \
{
variable idle 0
array set cap \
{
logindisabled 0
logindisabled_chk 0
chk 0
idle 0
esearch 0
}
variable search
variable rexp_chk
variable rexpsearch
variable sock
variable conf
array set conf {}
variable tevent
array set tevent {}
variable teidi 0
variable teidf 0
variable ka 0
variable img_up
variable img_down
variable img_chk
variable new_messages 0
variable tag
}
proc define_pixmaps {} \
{
variable ::shared::img_up
variable ::shared::img_down
variable ::shared::img_chk
variable ::shared::conf
set img_up [ image create photo -format png -file $conf(png_up) ]
set img_down [ image create photo -format png -file $conf(png_down) ]
set img_chk [ image create photo -format png -file $conf(png_chk) ]
}
proc icon {} \
{
variable ::shared::img_up
variable ::shared::img_down
variable ::shared::img_chk
variable ::shared::conf
set pnginfo [ ::png::imageInfo $conf(png_up) ]
set png_up_w [ dict get $pnginfo width ]
set png_up_h [ dict get $pnginfo height ]
set pnginfo [ ::png::imageInfo $conf(png_down) ]
set png_down_w [ dict get $pnginfo width ]
set png_down_h [ dict get $pnginfo height ]
set pnginfo [ ::png::imageInfo $conf(png_chk) ]
set png_chk_w [ dict get $pnginfo width ]
set png_chk_h [ dict get $pnginfo height ]
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 ] ]
if { $size_not_same } { emit_error size }
wm title . idlebiff
wm iconname . idlebiff
append geom [ + $png_up_w 10 ] x [ + $png_up_h 10 ]
if {[ and $conf(pos_x) $conf(pos_y) ]} { append geom + $conf(pos_x) + $conf(pos_y) }
wm geometry . $geom
wm resizable . 0 0
wm overrideredirect . $conf(nodecor)
frame .frame
pack .frame -expand 1 -fill both
canvas .frame.canvas -width $png_up_w -height $png_up_h -bd 10 -relief sunken
pack .frame.canvas -expand 1 -fill both
define_pixmaps
button .frame.canvas.button -image $img_chk -cursor left_ptr
.frame.canvas create window 0 0 -window .frame.canvas.button -anchor nw
pack .frame.canvas.button -expand 1 -fill both
bind .frame.canvas.button force_down
bind .frame.canvas.button { cleanup $con }
bind .frame.canvas.button { idle_cycle $con 1 }
}
proc get_password bad \
{
variable ::shared::conf
upvar #0 pass pass
if { $bad } { set textbad "WRONG PASSWORD\n" } \
else { set textbad {} }
if { !$conf(tls) } { set nv "\nWARNING: INSECURE CONNECTION" }
toplevel .tl
wm title .tl {idlebiff: password}
message .tl.msg -bg black -fg yellow -width 400 -justify center \
-text "${textbad}Enter password for\n$conf(user) at $conf(host)"
pack .tl.msg -side top -fill both
entry .tl.entry -relief sunken -bg grey -fg black -width 30 -show *
pack .tl.entry -side top
button .tl.bok -text OK -padx 20 -command { set pass [ .tl.entry get ]; destroy .tl }
pack .tl.bok -side right
bind .tl.entry { .tl.bok invoke }
button .tl.bc -text Cancel -command { cleanup $con } -padx 20
pack .tl.bc -side left
focus .tl.entry
vwait pass
set p $pass
unset pass
return $p
}
proc update_img img \
{
variable ::shared::conf
variable ::shared::new_messages
.frame.canvas.button configure -image $img
if { $conf(tip) } { tooltip::tooltip .frame.canvas $new_messages }
}
proc read_conf {} \
{
upvar #0 argv arg argc argn
variable ::shared::conf
if {[ == $argn 1 ]} { set config $arg } else { set config "$::env(HOME)/.idlebiffrc" }
set cf [ open $config ]
while {[ >= [ gets $cf lin ] 0 ]} \
{
if {[ == [ string index [ string trim $lin ] 0 ] # ]} continue
set lconf [ split $lin = ]
set val [ join [ lassign $lconf key ] = ]
set conf([ string trim $key]) [ string trim $val ]
}
}
proc set_params {} \
{
variable ::shared::rexp_chk
check_conf_and_set_default criteria UNSEEN
check_conf_and_set_default nodecor 1
check_conf_and_set_default tip 0
check_conf_and_set_default tls 0
check_conf_and_set_default tls_validate_cert 1
check_conf_and_set_default initial_noop 0
check_conf_and_set_default imap_trace 0
set rexp_chk {(?: |\[)capability }
}
proc set_search_params {} \
{
variable ::shared::cap
variable ::shared::search
variable ::shared::rexpsearch
variable ::shared::conf
if { $cap(esearch) } \
{
set search "SEARCH RETURN (COUNT) $conf(criteria)"
set rexpsearch {^\* esearch \(tag [^)]*\) count (.*)$}
} \
else \
{
set search "SEARCH $conf(criteria)"
set rexpsearch {^\* search((?: .*|$))}
}
}
proc emit_error args { error $args }
proc set_timeout {} \
{
variable ::shared::conf
variable ::shared::tevent
variable ::shared::teidf
set tevent($teidf) [ after ${conf(timeout)}000 \
{
update_img $::shared::img_chk
cancel_timeout
close $con
init
} \
]
incr teidf
}
proc cancel_timeout {} \
{
variable ::shared::tevent
variable ::shared::teidi
if {[ info exists tevent($teidi) ]} \
{
after cancel $tevent($teidi)
unset tevent($teidi)
incr teidi
}
}
proc exec_terse { com args } \
{
if {[ catch { set res [ eval "$com [ join $args]" ] } ]} \
{
set terse [ split $::errorInfo "\n" ]
puts [ join [ lrange $terse 0 0 ] ]
exit 1
}
return $res
}
proc recon con \
{
variable ::shared::ka
variable ::shared::idle
variable ::shared::img_chk
after cancel $ka
update_img $img_chk
set idle 0
catch { close $con }
after 3000 init
}
proc imap_command { con com } \
{
variable ::shared::conf
if { $conf(imap_trace) } \
{ if {[ regexp {.*LOGIN.*} $com ]} { puts "C: [string range $com 0 10]" } else { puts "C: $com" } }
set r [ catch { puts $con $com } ]
if {[ eq $com LOGOUT ]} return
if { $r } \
{
recon $con
return
}
set_timeout
}
proc imap_command_tagged { con com { prefix A } } \
{
variable ::static::ntag
variable ::shared::tag
if {[ == $ntag 10000 ]} { set ntag 1 }
setappend tag $prefix [ format %04d $ntag ]
append tcom $tag { } $com
imap_command $con $tcom
incr ntag
}
proc ok_tagged { linea { tag {A\d+} } } \
{
append rexp {^} $tag { ok.*$}
return [ regexp -lineanchor -nocase $rexp $linea ]
}
proc ok_untagged linea \
{
return [ regexp -lineanchor -nocase {^\* ok.*$} $linea ]
}
proc no_tagged linea \
{
return [ regexp -lineanchor -nocase {^A\d+ no.*$} $linea ]
}
proc login { con retr } \
{
variable ::shared::conf
variable ::shared::cap
if { $cap(logindisabled) } { emit_error logindisabled }
if { $retr } { unset conf(pass) }
if { ![ info exists conf(pass) ] } { set conf(pass) [ get_password $retr ] }
imap_command_tagged $con "LOGIN $conf(user) $conf(pass)"
}
proc logout con \
{
variable ::shared::conf
fileevent $con readable { parse_logout $con }
after ${conf(timeout_logout)}000 \
{
close $con
exit 0
}
imap_command_tagged $con LOGOUT
}
proc cleanup con \
{
variable ::shared::ka
variable ::shared::img_chk
variable ::shared::idle
update_img $img_chk
after cancel $ka
if { $idle } { exit_idle $con }
logout $con
}
proc force_down {} \
{
variable ::shared::img_down
update_img $img_down
}
proc enter_idle con \
{
imap_command_tagged $con IDLE
}
proc exit_idle con \
{
variable ::shared::idle
set idle 0
imap_command $con DONE
}
proc enter_mailbox con \
{
variable ::shared::conf
imap_command_tagged $con "EXAMINE $conf(mailbox)"
}
proc exit_mailbox con \
{
imap_command_tagged $con CLOSE
}
proc do_search con \
{
variable ::shared::search
variable ::shared::img_chk
update_img $img_chk
imap_command_tagged $con $search
}
proc check_cap { linea args } \
{
variable ::shared::cap
variable ::shared::rexp_chk
if {[ regexp -nocase -lineanchor $rexp_chk $linea ]} \
{
if {[ eq $args logindisabled ]} { set cap(logindisabled_chk) 1 } \
else { set cap(chk) 1 }
foreach capname $args \
{
if { !$cap($capname) } \
{
set rexp_cap " ${capname}(?: |]|$)"
set cap($capname) [ regexp -nocase -lineanchor $rexp_cap $linea ]
}
}
}
}
proc idle_cycle { con check } \
{
variable ::shared::conf
variable ::shared::ka
after cancel $ka
exit_idle $con
if { $check } { do_search $con } \
else { enter_idle $con }
set ka [ after ${conf(keepalive)}000 { idle_cycle $con 0 } ]
}
proc init_idle con \
{
variable ::shared::conf
variable ::shared::ka
variable ::shared::cap
if { !$cap(idle) } { emit_error noidle }
fileevent $con readable { parse_idle_check $con }
enter_mailbox $con
set_search_params
do_search $con
set ka [ after ${conf(keepalive)}000 { idle_cycle $con 0 } ]
}
proc parse_greeting con \
{
variable ::shared::cap
set linea [ read_line $con ]
check_cap $linea logindisabled
if {[ ok_untagged $linea ]} \
{
if { $cap(logindisabled_chk) } \
{
fileevent $con readable { parse_login $con }
login $con 0
} \
else \
{
fileevent $con readable { parse_logindisabled $con }
imap_command_tagged $con CAPABILITY
}
}
}
proc parse_logindisabled con \
{
variable ::shared::cap
set linea [ read_line $con ]
check_cap $linea logindisabled
if {[ ok_tagged $linea ]} \
{
if { $cap(logindisabled_chk) } \
{
fileevent $con readable { parse_login $con }
login $con 0
}
}
}
proc parse_login con \
{
variable ::shared::cap
set linea [ read_line $con ]
check_cap $linea idle esearch
if {[ ok_tagged $linea ]} \
{
if { $cap(chk) } { init_idle $con } \
else \
{
fileevent $con readable { parse_cap $con }
imap_command_tagged $con CAPABILITY
}
} \
elseif {[ no_tagged $linea ]} { login $con 1 }
}
proc parse_cap con \
{
set linea [ read_line $con ]
check_cap $linea idle esearch
if {[ ok_tagged $linea ]} { init_idle $con }
}
proc parse_idle_check con \
{
variable ::shared::idle
variable ::shared::ka
variable ::shared::conf
if {[ catch { set linea [ read_line $con ] } ]} return
set prim [ string range $linea 0 0 ]
if {[ eq $prim + ]} { set idle 1 } \
elseif { $idle } \
{
if {[ eq $prim * ]} \
{
if {[ ok_untagged $linea ]} \
{
after cancel $ka
set ka [ after ${conf(keepalive)}000 { idle_cycle $con 0 } ]
} \
else { idle_cycle $con 1 } \
} \
} \
else { parse_search $con $linea }
}
proc parse_search { con linea } \
{
variable ::shared::conf
variable ::shared::img_up
variable ::shared::img_down
variable ::shared::new_messages
variable ::shared::cap
variable ::shared::rexpsearch
variable ::static::pmsgs
variable ::static::search_resp
if {[ regexp -lineanchor -nocase $rexpsearch $linea -> res ]} \
{
if { $cap(esearch) } { set pmsgs $res } else { incr pmsgs [ llength $res ] }
set search_resp 1
}
if {[ and $search_resp [ ok_tagged $linea ] ]} \
{
enter_idle $con
set new_messages $pmsgs
set pmsgs 0
set search_resp 0
if {[ > $new_messages 0 ]} \
{ set current $img_up } \
else { set current $img_down }
update_img $current
}
if {[ no_tagged $linea ]} { emit_error search }
}
proc parse_logout con \
{
variable ::shared::conf
variable ::shared::tag
if {[ catch { gets $con linea } ]} return
if { $conf(imap_trace) } { puts "S: $linea" }
if {[ ok_tagged $linea $tag ]} \
{
close $con
exit 0
}
}
proc read_line con \
{
variable ::shared::conf
cancel_timeout
if {[ catch { set r [ gets $con line ] } ]} \
{
recon $con
return -code error
}
if {[ regexp -lineanchor -nocase {^\* bye(?: .*|$)} $line ]} \
{
recon $con
return -code error
}
if {[ and [ < $r 1 ] [ eof $con ] ]} \
{
recon $con
return -code error
} \
else \
{
if { $conf(imap_trace) } { puts "S: $line" }
return $line
}
}
proc parse_cert_subject sub \
{
set cst [ split [ string map { , = / = } $sub ] = ]
foreach v $cst { lappend cstp [ string trim $v ] }
return $cstp
}
proc tls_cb { type channel depth cert status error } \
{
variable ::shared::conf
variable ::static::ccsu
variable ::static::ccsp
array set ca $cert
if {[ info exists conf(tls_ca_subject) ]} \
{
if {[ and [ ne $conf(tls_ca_subject) {} ] [ eq $type verify ] [ eq $ca(subject) $ca(issuer) ] ]} \
{
array set csu [ parse_cert_subject $ca(subject) ]
if { !$ccsp } \
{
array set ccsu [ parse_cert_subject $conf(tls_ca_subject) ]
set ccsp 1
}
foreach i [ array names ccsu ] \
{
if { ![ info exists csu($i) ] } { emit_error cert }
if {[ ne $csu($i) $ccsu($i) ]} { emit_error cert }
}
}
}
}
proc init_tls {} \
{
variable ::shared::conf
variable ::shared::sock
if { $conf(tls) } \
{
set tlsv [ package require tls ]
if {[ >= $tlsv 1.6.4 ]} { set tls1x {-tls1.1 1 -tls1.2 1} } \
else { set tls1x {} }
if {[ info exists conf(sni) ]} { set sni "-servername $conf(sni)" } \
else { set sni {} }
set sock "::tls::socket -ssl2 0 -ssl3 0 -tls1 1 $tls1x \
-cafile \"$conf(tls_ca_file)\" \
-cadir \"$conf(tls_ca_dir)\" \
-certfile \"$conf(tls_client_cert)\" \
-keyfile \"$conf(tls_client_key)\" \
-request \"$conf(tls_validate_cert)\" \
-require \"$conf(tls_validate_cert)\" \
-cipher \"$conf(tls_ciphers)\" \
$sni \
-command tls_cb"
} \
else { set sock socket }
}
proc init {} \
{
upvar #0 con con
variable ::shared::conf
variable ::shared::sock
set con [ exec_terse $sock -async $conf(host) $conf(port) ]
fconfigure $con -blocking 0 -buffering line -translation {crlf crlf}
fileevent $con readable { parse_greeting $con }
if { $conf(initial_noop) } { imap_command_tagged $con NOOP N }
}
read_conf
set_params
icon
init_tls
init
vwait forever