#!/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