sup-1.1/0000755000004100000410000000000014246427237012225 5ustar www-datawww-datasup-1.1/ReleaseNotes0000644000004100000410000002034514246427237014545 0ustar www-datawww-dataRelease 0.21.0: Several small features as well as polishing (including fetching a GPG key with a shortcut and unsubscribing from mailinglist using an url). Several old deprecated parts of sup have been modernized. Support for Ruby 1.9.3 has been dropped. Have a look in History.txt for the details. Release 0.20.0: We've got man pages (Mr. Andersson)! We've got OpenBSD support (Scott Bonds)! It is now possible to get your HTML emails indexed by setting up a mime-decode hook before you index (Scott Bonds)! Scott Bonds also fixed up special character handing in source URIs. It is now possible to set up a goto hook for opening the URL below the cursor. Also a few long standing bugs have been fixed, and new GPG keys have been made for the tests. Release 0.19.0: New hook: check-attachment and a new option to shows dates in 24h format. Our old mailinglists have been closed with the shut down of Rubyforge. Please subscribe to our new list: supmua@googlegroups.com. Release 0.18.0: sup-tweak-labels works again. new color options and some bug fixes. Release 0.17.0: Bugfixes and new option for continous scrolling as well as an option for always editing messages in async mode. Release 0.16.0: Removed unfinished and abandoned sup-sync-back-mbox. Safer mime-view attachment file name handling, a temp file name is used while the extension is only used if it is alphanumeric. The migration script for YAML documents is now deprecated for ruby > 2.1 and will be removed in the future. Release 0.15.4: Bugfixes. Release 0.15.3: Revert non-functioning hidden_alternates option and fix bugs. Release 0.15.2: Use form_driver_w when available. New hidden_alternates option. Release 0.15.1: Sort threads last-activity-first and bug fix. Release 0.15.0: Maildir Syncback has been included. Refer to the wiki for more information on how to set it up. sup-sync-back has been moved to sup-sync-back-mbox, please make sure you make any needed changes. Release 0.14.1.1: See 0.13.2.1. Release 0.13.2.1: Security advisory (#SBU1) for Sup We have been notified of an potential exploit in the somewhat careless way Sup treats attachment metadata in received e-mails. The issues should now be fixed and I have released Sup 0.13.2.1 and 0.14.1.1 which incorporates these fixes. Please upgrade immediately and also ensure that your mime-decode or mime-view hooks are secure [0], [1]. This is specifically related to using quotes (',") around filename or content_type which is already escaped using Ruby Shellwords.escape - this means that the string (content_type, filename) is intended to be used _without_ any further quotes. Please make sure that if you use .mailcap (non OSX systems), you do not quote the string. Credit goes to: joernchen of Phenoelit (http://phenoelit.de) who discovered and suggested fixes for these issues. [0] https://github.com/sup-heliotrope/sup/wiki/Viewing-Attachments [1] https://github.com/sup-heliotrope/sup/wiki/Secure-usage-of-Sup Release 0.14.1: Service release to 0.14.0 plus a predefined 'All mail' search. Release 0.14.0: CJK-compatability, Psych usage, thread safety, GPGME 2.0 support. Sup is now Ruby 1.9 based, and apart from RMail - ready for Ruby 2.0.0. Sup now uses Psych as a YAML parser (default by Ruby) and your previous configuration files (~/.sup/*.yaml) may need to be migrated or re-created for them to work with the new sup. A migration script is included for this. Check https://github.com/sup-heliotrope/sup/wiki/Migration-0.13-to-0.14 for the latest instructions. First back up your ~/.sup directory and index, after installing the new sup run: $ sup-psych-ify-config-files to migrate your files. You should now be all set for buisness. Release 0.13.2: FreeBSD compatability and more thread safe polling. Release 0.13.1: Another ruby 1.8 compatible release, various fixes. Release 0.13.0: Collection of bugfixes and stability fixes since 0.12.1. We now depend on our own ncursesw-sup fork. Release 0.12.1: This release changes the gem dependency on ncurses to ncursesw, which allows the gem to install cleanly on Ruby 1.9. The new sup-import-dump program applies labels to an existing index, which could be done with sup-sync before 0.12. Release 0.12: Deprecated remote sources have been removed. Maildir support has been improved to gracefully handle messages that move or disappear. The "out of sync" errors should no longer occur. Inline GPG is now supported. Release 0.11: The deprecated Ferret index has been removed. Remote sources (IMAP, IMAPS, and mbox+ssh) have been deprecated and will be removed in 0.12. Tools like offlineimap, fetchmail, and rsync provide a much better user experience for these mail sources than Sup would ever be able to by itself. If your terminal supports it you can now use 256 colors in your colorscheme. Run the contrib/colorpicker.rb program to get the color names to put in colors.yaml. Saved searches are now supported. Hit '%' in search-results-mode to save the current search, and enter an empty search string to open the list of saved searches. Release 0.10: The Xapian backend is now the default. Convert your old, crash-prone Ferret index to Xapian by running sup-convert-ferret-index. Using a Ferret backend will produce a deprecation notice, and will not be supported in 0.11. Many thanks to Rich Lane. Release 0.9.1: This is mainly a bugfix release, with a couple minor new features rolled up. If you are using the Ferret backend, consider convering soon. I will probably add a deprecation notice in 0.10, and you support will be dropped in 0.11. Release 0.9: There's a new Xapian backend as an alternative to the Ferret one. It's still in a beta stage. It's much faster and much less prone to the random crashes than Ferret, but certain things don't work yet, most noticeably the unread message counts in label-list-mode. You can switch back and forth between both indexes without harm, *except* any new messages added to the one index won't be picked up by the other. Follow these instructions: To TRY the Xapian index, without screwing Ferret up: 1. sup-dump > dump # takes a while 2. export SUP_INDEX=xapian # or however you do it in your shell 3. sup-sync --all --all-sources --restore dump # takes a long time 4. sup -n # -n ensures that no polling is done. don't hit 'P' either Step 1 will take a long time, and step 3 will take a very long time. At this point, whenever you run Sup, the SUP_INDEX environment variable will determine which index you use. If it's unset, or "ferret", you will use the ferret index. If it's "xapian", the Xapian index. Make sure when you run sup with the Xapian index, you use -n and don't hit 'P', to avoid loading new messages into it. If you want to switch to Xapian permanently, you can then: 1. rm -rf ~/.sup/ferret 2. permanently set SUP_INDEX=xapian according to your shell 3. Run sup as normal, i.e. without -n. If you want to go back to Ferret, you can just rm -rf ~/.sup/xapian and make sure your SUP_INDEX environment variable is unset. Release 0.8.1: A bugfix release with fixes for quote parsing (bad behavior in certain long emails), multibyte display for non-utf8 locales, and reply-mode mode selection. Release 0.8: The big wins are undo support, mbox splitting fixes, and the various UI speedups and bugfixes. Parsing new email should also be faster, although IMAP remains tragically slow, as usual. Release 0.7: The big win in this release is that Ferret index corruption issues should now be fixed, thanks to an extensive programming of locking and thread-safety-adding. The other nice change is that text entry will now scroll to the right upon overflow, thanks to some arcane Curses magic that Steve Goldman discovered. As always, this release includes many other bugfixes and enhancements. Release 0.6: Message attachment searchability automatically takes effect on new messages, but if you want it on older ones, you'll have to reindex them. See the instructions below, and the help for sup-sync, for how to do this. Release 0.5: Saving message state (pressing "$") has been sped up. However, this is only automatically in effect for new messages. To make it effective for older messages (i.e. messages indexed with versions of Sup before 0.5), you must reindex them, e.g. by running sup-sync --all on a source. sup-1.1/devel/0000755000004100000410000000000014246427237013324 5ustar www-datawww-datasup-1.1/devel/load-index.rb0000644000004100000410000000021614246427237015674 0ustar www-datawww-datarequire 'sup' puts "loading index..." @index = Redwood::Index.new @index.load @i = @index.index puts "loaded index of #{@i.size} messages" sup-1.1/devel/profile.rb0000644000004100000410000000046114246427237015312 0ustar www-datawww-datarequire 'ruby-prof' require "redwood" result = RubyProf.profile do Redwood::ThreadSet.new(ARGV.map { |fn| Redwood::MBox::Scanner.new fn }).load_n_threads 100 end printer = RubyProf::GraphHtmlPrinter.new(result) File.open("profile.html", "w") { |f| printer.print(f, 1) } puts "report in profile.html" sup-1.1/devel/console.sh0000755000004100000410000000006214246427237015323 0ustar www-datawww-data#!/bin/sh irb -I lib -r ./devel/start-console.rb sup-1.1/devel/start-console.rb0000644000004100000410000000007214246427237016445 0ustar www-datawww-datarequire 'sup' include Redwood start Index.init Index.load sup-1.1/devel/count-loc.sh0000644000004100000410000000015414246427237015563 0ustar www-datawww-data#!/bin/sh egrep ".rb$" Manifest.txt | xargs cat | grep -v "^ *$"|grep -v "^ *#"|grep -v "^ *end *$"|wc -l sup-1.1/test/0000755000004100000410000000000014246427237013204 5ustar www-datawww-datasup-1.1/test/gnupg_test_home/0000755000004100000410000000000014246427237016373 5ustar www-datawww-datasup-1.1/test/gnupg_test_home/secring.gpg0000644000004100000410000000347714246427237020537 0ustar www-datawww-dataW^y; z7o<\0v]Y_?kzWLM:uǧ &Ub\d̖l"Jd/㣷5RdrVKVbr0G|ؑtg)"]nBmWQrVW㌼5]zv{hzO${{ Gt]D= eڦ×5IV8)jx] ֚"#(\xH|(CI^5xVo`3TނXWBhYu6M"j93ᅢ?+fѠap'`%ErO[D8o 1]B"OkaGYw#rwk0F_3vfG ! tA ūM6d>ZC0 CVi_":d1)~,LX~b`!%19u޶RXr{kZf~ٝt%h~ Jk@ln զĵ4$l*\{" gL6eQf G qADTfsVkhS+,xX}T\kכ \V5r4 ?@QVDqnLTr!ŊN GˣuII3p_úd zzN@H> NiHD=rOP5Fb0憎YAEOL9a'n>c(ˌaҀ)mQ6=6KMmrj.X>]d+lnmD V"&rRBzt1GB[1IDUGbXn@Kgny.!]_Lw1/5xd.h*5sJoq6҈o!(EWὍAI;[l >{Qԝ ˻0I&sk) [&h{(MIB/Bu?"x Mzqoo^lk#cgm3eJb$T${nvP+TmcVڡ%zB}e)6?[eCC)hI66XNqW =*6·";ُ4}:J.T*A;j_R3sS}l aiv]Y\7l k4 T 1_K⏞ta BNj W)AݸC(] $YZ'iԊ~Gd;0jA>YI}^:F^+;uE]"(A5!}~:~4[;OntLfcpgRlI!Tԃxsup-test-1@foo.bar 8!zZ z:ෂ^y;    :ෂ o$L\;dbhȿĤ3S$+y9a5ܖרOZx331Lth۪%0aG?{-+)*2:b],2FD6H٪u [3J +J֑6X|Ï3跜6=&uT>p~V:ɯ{MĘ萛@s7<9L'bvp+3Jcځz<[<\ӡUt"0 92N򭻏oVo,[tIdFgN&UV ׇt}_8=NZeOg2ې;.o nJTɋ(REsup-1.1/test/gnupg_test_home/gpg.conf0000644000004100000410000000016614246427237020022 0ustar www-datawww-datatrust-model always # Set preferred hash algo to the one expected in the test suite personal-digest-preferences sha256 sup-1.1/test/gnupg_test_home/pubring.gpg0000644000004100000410000000346214246427237020545 0ustar www-datawww-data^y; z7o<\0v]Y_?kzWLM:uǧ &Ub\d̖l"Jd/㣷5RdrVKVbr0G|ؑtg)"]nBmWQrVW㌼5]zv{hzO${{ Gt]D= eڦ×5IV8)jx] ֚"#(\xH|(CI^5xVo`3TނXWBhYu6M"j93ᅢ?+fѠap'`%ErO[D8o 1]B"OkaGYw#rwk0F_3vfG ! tA gpgsup-test-1@foo.bar gpg 8!zZ z:ෂ^y;    :ෂ o$L\;dbhȿĤ3S$+y9a5ܖרOZx331Lth۪%0aG?{-+)*2:b],2FD6H٪u [3J +J֑6X|Ï3跜6=&uT>p~V:ɯ{MĘ萛@s7<9L'bvp+3Jcځz<[<\ӡUt"0 92N򭻏oVo,[tIdFgN&UV ׇt}_8=NZeOg2ې;.o nJTɋ(REgpg^y9 jƇGhtܰA +#oTj>W1?xshaJ:ԴLX'. @&-&˵GfA:Mp/[ 0ߖ0heoFޡiT"QvVkQp;aY'l,)MR8d>HmPm͊[ gpgsup-test-2@foo.bar gpg 8!{JW35}lٱ|Ŵo^y9    ٱ|ŴovL XiX|44IЯOi]$퇛9*TYX3i /nɧ2@kC :,M?n/(oc 8SA̲\^O.゠ )[zn,nyu㱼d5K+SŒx6oID$P-q(HRP 윿< fAiDlw݁*v=nW" WhtܰA +#oTj>W1?xshaJ:ԴLX'. @&-&˵GfA:Mp/[ 0ߖ0heoFޡiT"QvVkQp;aY'l,)MR8d>HmPm͊[ gpgsup-test-2@foo.bar gpg 8!{JW35}lٱ|Ŵo^y9    ٱ|ŴovL XiX|44IЯOi]$퇛9*TYX3i /nɧ2@kC :,M?n/(oc 8SA̲\^O.゠ )[zn,nyu㱼d5K+SŒx6oID$P-q(HRP 윿< fAiDlw݁*v=nW" Wreceiver_secring.gpg echo "Backing up pubring.gpg for test receiver (file receiver_pubring.gpg)" cp -a pubring.gpg receiver_pubring.gpg echo "Clearing key store, so we can start from a blank slate for next key(s)" rm -f pubring.gpg trustdb.gpg private-keys-v1.d/*.key .gpg-v21-migrated echo "Generating key pair for sender (email sup-test-1@foo.bar)" touch pubring.gpg # So GPG 2.1+ writes to pubring.gpg instead of pubring.kbx gpg2 \ --homedir . \ --batch \ --pinentry-mode loopback \ --passphrase '' \ --quick-generate-key sup-test-1@foo.bar rsa encrypt,sign 0 echo "Importing public key for receiver, into sender's key store" gpg2 \ --homedir . \ --import sup-test-2@foo.bar.asc echo "Copy private key also to secring.gpg (old format used by GPG 1)" gpg2 \ --homedir . \ --export-secret-keys \ >secring.gpg echo "Done." echo "We now have two non-expiring public keys (receiver & sender):" gpg2 --homedir . --list-keys echo "And we also have only *one* corresponding private key (sender only):" gpg2 --homedir . --list-secret-keys popd sup-1.1/test/gnupg_test_home/receiver_secring.gpg0000644000004100000410000000350014246427237022406 0ustar www-datawww-dataX^y9 jƇGhtܰA +#oTj>W1?xshaJ:ԴLX'. @&-&˵GfA:Mp/[ 0ߖ0heoFޡiT"QvVkQp;aY'l,)MR8d>HmPm͊[ /^S%~%5yp׽!"r۱UHP{.//y]XfaJYUѥy&ը<6 ]l haQ,2g/'ձuYWkԡ-M@\ǟKue|Nu (}nZ!vͪ-4NW UwPӌdU'sR=Q5f :-ӂgקK.돹Q -P`CwD 'l)cl%LK]C -R[ ^^ a2 ٢ 5=hBCL𯻊Gd>]L8k)NcjSlb%ƍ5 SYܰsψ42WްAڤGpnO4njJ;zO>S:f <,eV2mcۥdedBG6/?"R(q? ßдx,qy % wx!Pw)X@J8WZA-}^wxL8:Z{ׯ}j8LQNt;>\)WN~m .T/*~zDY)'$;dZQ(,/:$:gޏ\Tԕ3=F^qm| VHȲC VvWsup-test-2@foo.bar 8!{JW35}lٱ|Ŵo^y9    ٱ|ŴovL XiX|44IЯOi]$퇛9*TYX3i /nɧ2@kC :,M?n/(oc 8SA̲\^O.゠ )[zn,nyu㱼d5K+SŒx6oID$P-q(HRP 윿< fAiDlw݁*v=nW" WZC0 CVi_":d1)~,LX~b`!%19u޶RXr{kZf~ٝt%h~ Jk@ln զĵ4$l*\{" gL6eQf G qADTfsVkhS+,xX}T\kכ \V5r4 ?@QVDqnLTr!ŊN GˣuII3p_úd zzN@H> NiHD=rOP5Fb0憎YAEOL9a'n>c(ˌaҀ))(1:p193:mQ6=6KMmrj.X>]d+lnmD V"&rRBzt1GB[1IDUGbXn@Kgny.!]_Lw1/5xd.h*5sJoq6҈o!(EWὍAI;[l >{Qԝ ˻0I&sk) [&h)(1:q193:{(MIB/Bu?"x Mzqoo^lk#cgm3eJb$T${nvP+TmcVڡ%zB}e)6?[eCC)hI66XNqW =*6·";ُ4}:J.T*A;j_R3sS}l aiv]Y\7)(1:u193:l k4 T 1_K⏞ta BNj W)AݸC(] $YZ'iԊ~Gd;0jA>YI}^:F^+;uE]"(A5!}~:~4[;OntLfcpgRlI!Tԃ)))sup-1.1/test/gnupg_test_home/.gpg-v21-migrated0000644000004100000410000000000014246427237021337 0ustar www-datawww-datasup-1.1/test/gnupg_test_home/sup-test-2@foo.bar.asc0000644000004100000410000000237614246427237022365 0ustar www-datawww-data-----BEGIN PGP PUBLIC KEY BLOCK----- mQGNBF7leTkBDAC3auy8xodH6jxoISylFZTpVqy/0L2ul879YUb/QbC58+F/H36S CjLfPxFlq0FAOXHelOvktxaybg+BG5UpSvTgBLbcArq5nctee+04TMXCzQzrG2V1 zb9gIRT665fX3+WYncSIXdr4LAp7r8Jw3RT3tTOZqbaencumCWaJblnvfFwPrMKf AXWa/NVndNMAXmJ5uBf1MRr45KXaQ2tczPIeHqSOKhKNnKZPRqPs0fg4i3d0Vb6G yItgtJapfBo50FV+PvtodMHo3LDlz/BBjdEJHSvghqEjb1S7xGo+hdXs+lfCMfa0 3PAWoj+OeHNorbK0YbVKOtS0E0xYvScbyC7bfwtA9yb3LZYmy7VHsKJmQfygCNQ6 wIKQGAVN1NcQcJsvWyAwk9+WMN5oqB5lb76u40beoWlUjSJRlph2VvWvkGuh/huU sVGqcN7EO4SFkwi2YQLoWfQRGur3mids/PQTBywpGE1SyziPZK76pT6SqP8b+OpI CG1QbcTZzYpbv6kAEQEAAbQSc3VwLXRlc3QtMkBmb28uYmFyiQHOBBMBCgA4FiEE e0oXvVeqMzUcfd1s2bF8xbTizW8FAl7leTkCGw8FCwkIBwIGFQoJCAsCBBYCAwEC HgECF4AACgkQ2bF8xbTizW92TAv/WGlYfDTKNEmJ0K+kxt33T2ldmZXaJKL04Mft h5s5KlRZWDNpkCC/L55uyaeEg+Uy+BEEQKLAEeJrrLMV8UMJwMPDOizSTT9uLyiz b8RjnQw4iMT8wt9TQboXGaTMslwdXvFPii7w44KgCimE7VuPetJuLMLMbnl147G8 +QhkNUsrB51TuPS8xZJ4qjbH+K/Y2NlvwLtJrxNE3SRQuy2ApYJxKPZIj1KpUL8M 7Jy/2hI8DaRm/0Fpu8HwRIVsd6/dgdkqdj1uVyLj+wyhgdzqV5WrPLFCRVhd3icd lPNRIDjg8YKCh353LVHjKwefOW4SnkOPn4uVMdCP9gUFd9zpMP9lMFpjk0o0tcYO NiFrOclS4q5qZ5jrj1MnBF0NaGhuC83DDgRfKV+p5noVeJxg0nXYZSlsSMfAT/K7 FbdNEg0XUsrLgWVzhvWv/ebMetFPSfGHIveZ7lhiq1qpA5hLBNfSSBb1JJsFmtQt cEUluymdNe5W7Y6UGs1CpvcIvbj+ =Cy9S -----END PGP PUBLIC KEY BLOCK----- sup-1.1/test/fixtures/0000755000004100000410000000000014246427237015055 5ustar www-datawww-datasup-1.1/test/fixtures/text-attachments-with-charset.eml0000644000004100000410000000326514246427237023457 0ustar www-datawww-dataFrom: Fake Sender To: Fake Receiver Date: Sun, 21 Jun 2020 06:25:49 -0000 Subject: Attachments with charset MIME-Version: 1.0 Content-Type: multipart/mixed; boundary="===============2385509127900810307==" --===============2385509127900810307== Content-Type: text/plain; charset="utf-8" Content-Transfer-Encoding: 7bit This is the body. --===============2385509127900810307== Content-Type: text/plain; charset="us-ascii" Content-Transfer-Encoding: 7bit MIME-Version: 1.0 Content-Disposition: attachment; filename="ascii.txt" This is ASCII --===============2385509127900810307== Content-Type: text/plain; charset="koi8-r" Content-Transfer-Encoding: quoted-printable MIME-Version: 1.0 Content-Disposition: attachment; filename="cyrillic.txt" =F0=D2=C9=D7=C5=D4 --===============2385509127900810307== Content-Type: text/plain; charset="utf-8" Content-Transfer-Encoding: base64 MIME-Version: 1.0 Content-Disposition: attachment; filename="emoji.txt" 8J+Yggo= --===============2385509127900810307== Content-Type: text/plain Content-Transfer-Encoding: quoted-printable Content-Disposition: attachment; filename="bad.txt" MIME-Version: 1.0 Embedded=F0garbage --===============2385509127900810307== Content-Type: text/plain; charset="invalid-test"; name="invalid-charset.txt" Content-Transfer-Encoding: quoted-printable Content-Disposition: attachment; filename="invalid-charset.txt" Example invalid charset --===============2385509127900810307== Content-Type: text/plain; charset="utf-7" Content-Transfer-Encoding: 7bit MIME-Version: 1.0 Content-Disposition: attachment; filename="ascii.txt" This is +Jyg-UTF-7+Jyg- --===============2385509127900810307== sup-1.1/test/fixtures/rfc2047-header-encoding.eml0000644000004100000410000000113614246427237021656 0ustar www-datawww-dataFrom: test@example.invalid To: test@example.invalid Date: Sun, 19 Jul 2020 17:03:56 +1000 Subject: =?US-ASCII?q?Hans Martin Djupvik?= =?ISO-8859-1?q?,_Ingrid_B=F8?= =?KOI8-R?b?LCDp0snOwSDzycTP0s/XwQ?= =?UTF-16?b?//4sACAASgBlAHMAcABlAHIAIABCAGUAcgBnAA?= =?UTF-7?b?LCBGcmlkYSBFbmcrQVBnLQ?= bad: =?UTF16?q?badcharsetname?= =?US-ASCII?b?/w?= =?UTF-7?Q?=41=6D=65=72=69=63=61=E2=80=99=73?= The subject header contains various RFC2047 encoded words. For completeness we test both base64 and quoted-printable, and some ASCII-incompatible encodings. We also include some bogus words which cannot be decoded. sup-1.1/test/fixtures/no-body.eml0000644000004100000410000000141414246427237017123 0ustar www-datawww-dataReturn-path: From: Fake Sender To: Fake Receiver Envelope-to: fake_receiver@localhost Delivery-date: Sun, 09 Dec 2007 21:48:19 +0200 Received: from fake_sender by localhost.localdomain with local (Exim 4.67) (envelope-from ) id 1J1S8R-0006lA-MJ for fake_receiver@localhost; Sun, 09 Dec 2007 21:48:19 +0200 Date: Sun, 9 Dec 2007 21:48:19 +0200 Subject: Re: Test message subject Message-ID: <20071209194819.GA25972@example.invalid> References: MIME-Version: 1.0 Content-Type: text/plain; charset=us-ascii Content-Disposition: inline In-Reply-To: User-Agent: Sup/0.3sup-1.1/test/fixtures/missing-from-to.eml0000644000004100000410000000127714246427237020615 0ustar www-datawww-dataReturn-path: Envelope-to: fake_receiver@localhost Delivery-date: Sun, 09 Dec 2007 21:48:19 +0200 Received: from fake_sender by localhost.localdomain with local (Exim 4.67) (envelope-from ) id 1J1S8R-0006lA-MJ for fake_receiver@localhost; Sun, 09 Dec 2007 21:48:19 +0200 Date: Sun, 9 Dec 2007 21:48:19 +0200 Subject: Re: Test message subject Message-ID: <20071209194819.GA25972@example.invalid> References: MIME-Version: 1.0 Content-Type: text/plain; charset=us-ascii Content-Disposition: inline In-Reply-To: User-Agent: Sup/0.3 Test message!sup-1.1/test/fixtures/contacts.txt0000644000004100000410000000005214246427237017431 0ustar www-datawww-dataRC: Random Contact sup-1.1/test/fixtures/mailing-list-header.eml0000644000004100000410000000602114246427237021372 0ustar www-datawww-dataReturn-Path: Delivered-To: unknown Received: (qmail 702 invoked from network); 7 May 2020 06:15:54 -0000 Received: from web01.groups.io (HELO web01.groups.io) (66.175.222.12) by server-33.tower-414.messagelabs.com with ECDHE-RSA-AES256-GCM-SHA384 encrypted SMTP; 7 May 2020 06:15:54 -0000 From: "Yu, Mingli" To: Subject: [oe] [meta-python][PATCH] python3-ntplib: add missing python3-io RDEPENDS Date: Thu, 7 May 2020 14:15:26 +0800 Message-ID: <1588832126-393701-1-git-send-email-mingli.yu@windriver.com> Precedence: Bulk List-Unsubscribe: Sender: List-Id: Mailing-List: list openembedded-devel@lists.openembedded.org; contact openembedded-devel+owner@lists.openembedded.org Delivered-To: mailing list openembedded-devel@lists.openembedded.org Reply-To: Content-Type: multipart/mixed; boundary="YleAvGBsp4tLsYxU5fi4" MIME-Version: 1.0 --YleAvGBsp4tLsYxU5fi4 Content-Type: text/plain; charset="utf-8" Content-Transfer-Encoding: 7bit MIME-Version: 1.0 From: Mingli Yu Add the missing python3-io RDEPENDS to fix below error: # python3 Python 3.8.2 (default, Apr 27 2020, 08:51:00) [GCC 9.3.0] on linux Type "help", "copyright", "credits" or "license" for more information. >>> import ntplib Traceback (most recent call last): File "", line 1, in File "/usr/lib64/python3.8/site-packages/ntplib.py", line 32, in import socket ModuleNotFoundError: No module named 'socket' Signed-off-by: Mingli Yu --- meta-python/recipes-devtools/python/python3-ntplib_0.3.3.bb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/meta-python/recipes-devtools/python/python3-ntplib_0.3.3.bb b/meta-python/recipes-devtools/python/python3-ntplib_0.3.3.bb index 93df83a..ce2618b 100644 --- a/meta-python/recipes-devtools/python/python3-ntplib_0.3.3.bb +++ b/meta-python/recipes-devtools/python/python3-ntplib_0.3.3.bb @@ -11,4 +11,4 @@ S = "${WORKDIR}/${SRCNAME}-${PV}" inherit setuptools3 python3native pypi -RDEPENDS_${PN} += "${PYTHON_PN}-datetime" +RDEPENDS_${PN} += "${PYTHON_PN}-datetime ${PYTHON_PN}-io" -- 2.7.4 --YleAvGBsp4tLsYxU5fi4 Content-Type: text/plain; charset="utf-8" Content-Transfer-Encoding: quoted-printable Content-Disposition: inline -=3D-=3D-=3D-=3D-=3D-=3D-=3D-=3D-=3D-=3D-=3D- Links: You receive all messages sent to this group. View/Reply Online (#84234): https://lists.openembedded.org/g/openembedded-d= evel/message/84234 Mute This Topic: https://lists.openembedded.org/mt/74045486/3618174 Group Owner: openembedded-devel+owner@lists.openembedded.org Unsubscribe: https://lists.openembedded.org/g/openembedded-devel/leave/8024= 896/1667129725/xyzzy [dan.callaghan@opengear.com] -=3D-=3D-=3D-=3D-=3D-=3D-=3D-=3D-=3D-=3D-=3D- --YleAvGBsp4tLsYxU5fi4-- sup-1.1/test/fixtures/zimbra-quote-with-bottom-post.eml0000644000004100000410000000206714246427237023436 0ustar www-datawww-dataReturn-Path: Delivered-To: Received: from zmail16.collab.prod.int.phx2.redhat.com (zmail16.collab.prod.int.phx2.redhat.com [10.5.83.18]) by mx4-phx2.redhat.com (8.13.8/8.13.8) with ESMTP id q3A5xQ06025053 for ; Tue, 10 Apr 2012 01:59:26 -0400 Date: Tue, 10 Apr 2012 01:59:26 -0400 (EDT) From: Zimbra User To: Recipient Subject: Re: Zimbra Message-ID: <0fe105df-e67b-419e-8599-50aaff7260e8@zmail16.collab.prod.int.phx2.redhat.com> In-Reply-To: <1334037315-sup-8577@example.invalid> Content-Type: text/plain; charset=utf-8 Content-Transfer-Encoding: 7bit MIME-Version: 1.0 X-Mailer: Zimbra 7.1.2_GA_3268 (ZimbraWebClient - FF3.0 (Linux)/7.1.2_GA_3268) ----- Original Message ----- > From: "Recipient" > To: "Zimbra User" > Sent: Tuesday, April 10, 2012 3:56:15 PM > Subject: Re: Zimbra > > This is the quoted original message. > This is the reply from the Zimbra user. sup-1.1/test/fixtures/blank-header-fields.eml0000644000004100000410000000635214246427237021343 0ustar www-datawww-dataReturn-Path: X-Original-To: nobody@localhost Delivered-To: nobody@localhost.eng.widget.com Received: from localhost (localhost.localdomain [127.0.0.1]) by soquel.eng.widget.com (Postfix) with ESMTP id 609BC13C0DB1 for ; Thu, 19 Mar 2009 13:43:21 -0700 (PDT) MIME-Version: 1.0 Received: from pa-excas-vip.widget.com [10.16.67.200] by localhost with IMAP (fetchmail-6.2.5) for nobody@localhost (single-drop); Thu, 19 Mar 2009 13:43:21 -0700 (PDT) Received: from pa-exht01.widget.com (10.113.81.167) by pa-excaht11.widget.com (10.113.81.197) with Microsoft SMTP Server (TLS) id 8.1.311.2; Thu, 19 Mar 2009 13:42:30 -0700 Received: from mailman2.widget.com (10.16.64.159) by pa-exht01.widget.com (10.113.81.167) with Microsoft SMTP Server id 8.1.336.0; Thu, 19 Mar 2009 13:42:30 -0700 Received: by mailman2.widget.com (Postfix) id 47095AE30856; Thu, 19 Mar 2009 13:42:29 -0700 (PDT) Received: from countchocula.widget.com (localhost.localdomain [127.0.0.1]) by mailman2.widget.com (Postfix) with ESMTP id 5F782ABC5948; Thu, 19 Mar 2009 13:42:28 -0700 (PDT) Received: from mailhost4.widget.com (mailhost4.widget.com [10.16.67.124]) by mailman2.widget.com (Postfix) with ESMTP id 6CDCCABC5948 for ; Thu, 19 Mar 2009 13:42:26 -0700 (PDT) Received: by mailhost4.widget.com (Postfix) id 2364AC9AC4; Thu, 19 Mar 2009 13:42:26 -0700 (PDT) Received: from pa-exht01.widget.com (pa-exht01.widget.com [10.113.81.167]) by mailhost4.widget.com (Postfix) with ESMTP id 17A68C9AC3 for ; Thu, 19 Mar 2009 13:42:26 -0700 (PDT) Received: from PA-EXMBX04.widget.com ([10.113.81.142]) by pa-exht01.widget.com ([10.113.81.167]) with mapi; Thu, 19 Mar 2009 13:42:26 -0700 From: Some User To: "monitor-list@widget.com" Sender: "monitor-list-bounces@widget.com" Date: Thu, 19 Mar 2009 13:42:25 -0700 Subject: Looking for a mac Thread-Topic: Looking for a mac Thread-Index: AQHJqNM1xIqqjNRWuUCUBaxzPFK5eQ== Message-ID: List-Help: List-Subscribe: , List-Unsubscribe: , Accept-Language: en-US Content-Language: en-US X-MS-Exchange-Organization-AuthAs: Anonymous X-MS-Exchange-Organization-AuthSource: pa-exht01.widget.com X-MS-Has-Attach: X-Auto-Response-Suppress: All X-MS-TNEF-Correlator: acceptlanguage: en-US delivered-to: monitor-list@widget.com errors-to: monitor-list-bounces@widget.com list-id: engineering monitor related x-mailman-version: 2.1.8 x-beenthere: monitor-list@widget.com x-original-to: monitor-list@mailman2.widget.com list-post: list-archive: Content-Type: text/plain; charset="us-ascii" Content-Transfer-Encoding: quoted-printable Hi all, Just wondering if anybody can lend me a mac to reproduce PR 384931 ? Thanks. Michael=sup-1.1/test/fixtures/non-ascii-header.eml0000644000004100000410000000036514246427237020666 0ustar www-datawww-dataReturn-Path: From: SPAM To: Subject: spam spam Message-Id: <20120302063755.0FE2122017@a.a.a.a> Date: Fri, 2 Mar 2012 07:37:55 +0100 (CET) https://github.com/sup-heliotrope/sup/issues/205 sup-1.1/test/fixtures/binary-content-transfer-encoding-2.eml0000644000004100000410000000077714246427237024270 0ustar www-datawww-dataFrom: foo@example.org MIME-Version: 1.0 Content-type: multipart/report; boundary="======11647==82899======"; report-type="spam-notification" Subject: Important This is a multi-part message in MIME format... --======11647==82899====== Content-Type: text/plain; charset="ISO-8859-1" Content-Disposition: inline Content-Transfer-Encoding: quoted-printable --======11647==82899====== Content-Type: message/rfc822 Content-Disposition: attachment Content-Transfer-Encoding: binary --======11647==82899======-- sup-1.1/test/fixtures/malicious-attachment-names.eml0000644000004100000410000000405014246427237022767 0ustar www-datawww-dataFrom: Matthieu Rakotojaona To: reply+0007a7cb7174d1d188fcd420fce83e0f68fe03fc7416cdae92cf0000000110ce4efd92a169ce033d18e1 Subject: Re: [sup] Attachment saving and special characters in filenames (#378) In-reply-to: References: X-pgp-key: http://otokar.looc2011.eu/static/matthieu.rakotojaona.asc Date: Wed, 14 Jan 2015 22:13:37 +0100 Message-Id: <1421269972-sup-5245@kpad> User-Agent: Sup/git Content-Transfer-Encoding: 8bit MIME-Version: 1.0 Content-Type: multipart/mixed; boundary="=-1421270017-526778-1064-1628-1-=" --=-1421270017-526778-1064-1628-1-= Content-Type: text/plain; charset=UTF-8 Content-Disposition: inline Excerpts from Felix Kaiser's message of 2015-01-14 16:36:29 +0100: > When saving attachments, sup should replace special characters when suggesting a filename to save the attachment to. > > I just got an attachment with a name like "foo/2.pdf". sup suggests saving it to /home/fxkr/foo/2.pdf (and fails to save it, of course, if /home/fxkr/foo isn't a directory). > > I haven't tested the "Save All" feature, but I hope nothing bad happens when there's an attachment called "../../../../../../../home/fxkr/.bashrc" ;-) > > --- > Reply to this email directly or view it on GitHub: > https://github.com/sup-heliotrope/sup/issues/378 For tests, here's an email with an attachment filename set to sup/.travis.yml (really, this time) -- Matthieu Rakotojaona --=-1421270017-526778-1064-1628-1-= Content-Disposition: attachment; filename="sup/.travis.yml" Content-Type: text/x-yaml; name="sup/.travis.yml" Content-Transfer-Encoding: 8bit language: ruby rvm: - 2.1.1 - 2.0.0 - 1.9.3 before_install: - sudo apt-get update -qq - sudo apt-get install -qq uuid-dev uuid libncursesw5-dev libncursesw5 gnupg2 pandoc - git submodule update --init --recursive script: bundle exec rake travis --=-1421270017-526778-1064-1628-1-=--sup-1.1/test/fixtures/missing-line.eml0000644000004100000410000000041014246427237020145 0ustar www-datawww-dataFrom: foo@aol.com To: foo@test.com Subject: Encoding bug Content-Type: text/plain; charset="iso-8859-1" Content-Transfer-Encoding: quoted-printable This is =91 a test: the first line seems to disappear from the mail body but is still visible in the thread view. sup-1.1/test/fixtures/multi-part.eml0000644000004100000410000000417514246427237017661 0ustar www-datawww-dataFrom fake_receiver@localhost Sun Dec 09 22:33:37 +0200 2007 Subject: Re: Test message subject From: Fake Receiver To: Fake Sender References: <20071209194819.GA25972example.invalid> In-Reply-To: <20071209194819.GA25972example.invalid> Date: Sun, 09 Dec 2007 22:33:37 +0200 Message-Id: <1197232243-sup-2663example.invalid> User-Agent: Sup/0.3 Content-Type: multipart/mixed; boundary="=-1197232418-506707-26079-6122-2-=" MIME-Version: 1.0 --=-1197232418-506707-26079-6122-2-= Content-Type: text/plain; charset=utf-8 Content-Disposition: inline Excerpts from Fake Sender's message of Sun Dec 09 21:48:19 +0200 2007: > Test message! Thanks for the message! --=-1197232418-506707-26079-6122-2-= Content-Disposition: attachment; filename="HACKING" Content-Type: application/octet-stream; name="HACKING" Content-Transfer-Encoding: base64 UnVubmluZyBTdXAgbG9jYWxseQotLS0tLS0tLS0tLS0tLS0tLS0tCkludm9r ZSBpdCBsaWtlIHRoaXM6CgpydWJ5IC1JIGxpYiAtdyBiaW4vc3VwCgpZb3Un bGwgaGF2ZSB0byBpbnN0YWxsIGFsbCBnZW1zIG1lbnRpb25lZCBpbiB0aGUg UmFrZWZpbGUgKGxvb2sgZm9yIHRoZSBsaW5lCnNldHRpbmcgcC5leHRyYV9k ZXBzKS4gSWYgeW91J3JlIG9uIGEgRGViaWFuIG9yIERlYmlhbi1iYXNlZCBz eXN0ZW0gKGUuZy4KVWJ1bnR1KSwgeW91J2xsIGhhdmUgdG8gbWFrZSBzdXJl IHlvdSBoYXZlIGEgY29tcGxldGUgUnVieSBpbnN0YWxsYXRpb24sCmVzcGVj aWFsbHkgbGlic3NsLXJ1YnkuCgpDb2Rpbmcgc3RhbmRhcmRzCi0tLS0tLS0t LS0tLS0tLS0KCi0gRG9uJ3Qgd3JhcCBjb2RlIHVubGVzcyBpdCByZWFsbHkg YmVuZWZpdHMgZnJvbSBpdC4gVGhlIGRheXMgb2YKICA4MC1jb2x1bW4gZGlz cGxheXMgYXJlIGxvbmcgb3Zlci4gQnV0IGRvIHdyYXAgY29tbWVudHMgYW5k IG90aGVyCiAgdGV4dCBhdCB3aGF0ZXZlciBFbWFjcyBtZXRhLVEgZG9lcy4K LSBJIGxpa2UgcG9ldHJ5IG1vZGUuCi0gVXNlIHt9IGZvciBvbmUtbGluZXIg YmxvY2tzIGFuZCBkby9lbmQgZm9yIG11bHRpLWxpbmUgYmxvY2tzLgoK --=-1197232418-506707-26079-6122-2-= Content-Disposition: attachment; filename="Manifest.txt" Content-Type: text/plain; name="Manifest.txt" Content-Transfer-Encoding: quoted-printable HACKING History.txt LICENSE Manifest.txt README.txt Rakefile bin/sup bin/sup-add bin/sup-config bin/sup-dump bin/sup-recover-sources bin/sup-sync bin/sup-sync-back --=-1197232418-506707-26079-6122-2-=--sup-1.1/test/fixtures/embedded-message.eml0000644000004100000410000000155514246427237020735 0ustar www-datawww-dataReturn-Path: From: Sender To: Subject: Email with embedded message MIME-Version: 1.0 Content-Type: multipart/mixed; boundary="----------=_4F506AC2.EE281DC4" Message-Id: <9181f493-df49-4af5-8ad2-e1a8eb692a98> Date: Wed, 15 Jul 2020 19:48:41 +0100 This is a multi-part message in MIME format. ------------=_4F506AC2.EE281DC4 Content-Type: text/plain; charset=iso-8859-1 Content-Disposition: inline Content-Transfer-Encoding: 8bit Example outer message. Example second line. ------------=_4F506AC2.EE281DC4 Content-Type: message/rfc822; x-spam-type=original Content-Transfer-Encoding: 8bit From: "Embed sender" To: Subject: Embedded subject line Date: Wed, 15 Jul 2020 12:34:56 +0000 Example embedded message. Second line. ------------=_4F506AC2.EE281DC4-- sup-1.1/test/fixtures/non-ascii-header-in-nested-message.eml0000644000004100000410000000205514246427237024172 0ustar www-datawww-dataReturn-Path: From: SPAM To: Subject: spam spam MIME-Version: 1.0 Content-Type: multipart/mixed; boundary="----------=_4F506AC2.EE281DC4" Message-Id: <20120302063755.0FE2122017@a.a.a.a> Date: Fri, 2 Mar 2012 07:37:55 +0100 (CET) This is a multi-part message in MIME format. ------------=_4F506AC2.EE281DC4 Content-Type: text/plain; charset=iso-8859-1 Content-Disposition: inline Content-Transfer-Encoding: 8bit Spam detection software, running on the system "a.a.a.a.a.", has identified this incoming email as possible spam. The original message has been attached to this so you can view it (if it isn't spam) or label similar future email. ------------=_4F506AC2.EE281DC4 Content-Type: message/rfc822; x-spam-type=original Content-Description: original message before SpamAssassin Content-Disposition: attachment Content-Transfer-Encoding: 8bit From: SPAM To: Subject: spam spam This is a spam. ------------=_4F506AC2.EE281DC4-- sup-1.1/test/fixtures/simple-message.eml0000644000004100000410000000232614246427237020472 0ustar www-datawww-dataReturn-path: Envelope-to: fake_receiver@localhost Delivery-date: Sun, 09 Dec 2007 21:48:19 +0200 Received: from fake_sender by localhost.localdomain with local (Exim 4.67) (envelope-from ) id 1J1S8R-0006lA-MJ for fake_receiver@localhost; Sun, 09 Dec 2007 21:48:19 +0200 Date: Sun, 9 Dec 2007 21:48:19 +0200 Mailing-List: contact example-help@example.invalid; run by ezmlm Precedence: bulk List-Id: List-Post: List-Help: List-Unsubscribe: List-Subscribe: Delivered-To: mailing list example@example.invalid Delivered-To: moderator for example@example.invalid From: Fake Sender To: Fake Receiver Subject: Re: Test message subject Message-ID: <20071209194819.GA25972@example.invalid> References: MIME-Version: 1.0 Content-Type: text/plain; charset=us-ascii Content-Disposition: inline In-Reply-To: User-Agent: Sup/0.3 Test message!sup-1.1/test/fixtures/utf8-header.eml0000644000004100000410000000133414246427237017671 0ustar www-datawww-dataDelivered-To: djc@djc.id.au Received: from orpheus.librarything.com (orpheus.librarything.com [74.201.105.9]) by djc.id.au (Postfix) with ESMTP id A0CCB20AAB99 for ; Sat, 23 Jan 2021 02:52:15 +1000 (AEST) Received: by orpheus.librarything.com (Postfix, from userid 0) id 21B172C20F9; Fri, 22 Jan 2021 11:52:08 -0500 (EST) To: djc@djc.id.au Subject: LibraryThing: State of the Thing — January MIME-Version: 1.0 Content-type: text/html; charset=iso-8859-1 From: tim@librarything.com Reply-To: tim@librarything.com Message-Id: <20210122165208.21B172C20F9@orpheus.librarything.com> Date: Fri, 22 Jan 2021 11:52:08 -0500 (EST) Return-Path:

Some stuff

sup-1.1/test/fixtures/multi-part-2.eml0000644000004100000410000000623414246427237020016 0ustar www-datawww-dataReturn-path: Envelope-to: fake_receiver@localhost Delivery-date: Wed, 14 Jun 2006 19:22:54 +0300 Received: from localhost ([127.0.0.1] helo=localhost.localdomain) by localhost.localdomain with esmtp (Exim 4.60) (envelope-from ) id 1FqXk3-0006jM-48 for fake_receiver@localhost; Wed, 14 Jun 2006 18:57:15 +0300 Received: from pop.gmail.com by localhost.localdomain with POP3 (fetchmail-6.3.2) for (single-drop); Wed, 14 Jun 2006 18:57:15 +0300 (EEST) X-Gmail-Received: 8ee0fe5f895736974c042c8eaf176014b1ba7b88 Delivered-To: fake_receiver@localhost Received: by 10.49.8.16 with SMTP id l16cs11327nfi; Sun, 26 Mar 2006 19:31:56 -0800 (PST) Received: by 10.66.224.8 with SMTP id w8mr2172862ugg; Sun, 26 Mar 2006 19:31:56 -0800 (PST) Received: from foobar.math.fu-berlin.de (foobar.math.fu-berlin.de [160.45.45.151]) by mx.gmail.com with SMTP id j3si553645ugd.2006.03.26.19.31.56; Sun, 26 Mar 2006 19:31:56 -0800 (PST) Received-SPF: neutral (gmail.com: 160.45.45.151 is neither permitted nor denied by best guess record for domain of vim-mac-return-3938-fake_receiver=localhost@vim.org) Message-Id: <44275cac.74a494f1.315a.ffff825cSMTPIN_ADDED@mx.gmail.com> Received: (qmail 24265 invoked by uid 200); 27 Mar 2006 02:32:39 -0000 Mailing-List: contact vim-mac-help@vim.org; run by ezmlm Precedence: bulk Delivered-To: mailing list vim-mac@vim.org Received: (qmail 7913 invoked from network); 26 Mar 2006 23:37:34 -0000 Received: from cpe-138-217-96-243.vic.bigpond.net.au (HELO vim.org) (138.217.96.243) by foobar.math.fu-berlin.de with SMTP; 26 Mar 2006 23:37:34 -0000 From: fake_sender@example.invalid To: vim-mac@vim.org Subject: Mail Delivery (failure vim-mac@vim.org) Date: Mon, 27 Mar 2006 10:29:39 +1000 MIME-Version: 1.0 Content-Type: multipart/related; type="multipart/alternative"; boundary="----=_NextPart_000_001B_01C0CA80.6B015D10" X-Priority: 3 X-MSMail-Priority: Normal ------=_NextPart_000_001B_01C0CA80.6B015D10 Content-Type: multipart/alternative; boundary="----=_NextPart_001_001C_01C0CA80.6B015D10" ------=_NextPart_001_001C_01C0CA80.6B015D10 Content-Type: text/plain; charset="iso-8859-1" Content-Transfer-Encoding: quoted-printable ------=_NextPart_001_001C_01C0CA80.6B015D10 Content-Type: text/html; charset="iso-8859-1" Content-Transfer-Encoding: quoted-printable If the message will not displayed automatically,
follow the link to read the delivered message.

Received message is available at:
www.vim.org/inbox/vim-mac/read.php?sessionid-18559
 
------=_NextPart_001_001C_01C0CA80.6B015D10-- ------=_NextPart_000_001B_01C0CA80.6B015D10--sup-1.1/test/fixtures/bad-content-transfer-encoding-1.eml0000644000004100000410000000026314246427237023517 0ustar www-datawww-dataFrom: foo@example.org MIME-Version: 1.0 Subject: Content-Transfer-Encoding:-bug in sup Content-Type: message/rfc822 Content-Transfer-Encoding: nosuchcontenttransferencoding foo sup-1.1/test/test_message.rb0000644000004100000410000003357114246427237016225 0ustar www-datawww-data#!/usr/bin/ruby require 'test_helper' require 'sup' require 'stringio' require 'dummy_source' module Redwood class TestMessage < Minitest::Test def setup @path = Dir.mktmpdir Redwood::HookManager.init File.join(@path, 'hooks') end def teardown Redwood::HookManager.deinstantiate! FileUtils.rm_r @path end def test_simple_message source = DummySource.new("sup-test://test_simple_message") source.messages = [ fixture_path('simple-message.eml') ] source_info = 0 sup_message = Message.build_from_source(source, source_info) sup_message.load_from_source! # see how well parsing the header went to = sup_message.to assert(to.is_a? Array) assert(to.first.is_a? Person) assert_equal(1, to.length) # sup doesn't do capitalized letters in email addresses assert_equal("fake_receiver@localhost", to[0].email) assert_equal("Fake Receiver", to[0].name) from = sup_message.from assert(from.is_a? Person) assert_equal("fake_sender@example.invalid", from.email) assert_equal("Fake Sender", from.name) subj = sup_message.subj assert_equal("Re: Test message subject", subj) list_subscribe = sup_message.list_subscribe assert_equal("", list_subscribe) list_unsubscribe = sup_message.list_unsubscribe assert_equal("", list_unsubscribe) list_address = sup_message.list_address assert_equal("example@example.invalid", list_address.email) assert_equal("example", list_address.name) date = sup_message.date assert_equal(Time.parse("Sun, 9 Dec 2007 21:48:19 +0200"), date) id = sup_message.id assert_equal("20071209194819.GA25972@example.invalid", id) refs = sup_message.refs assert_equal(1, refs.length) assert_equal("E1J1Rvb-0006k2-CE@localhost.localdomain", refs[0]) replytos = sup_message.replytos assert_equal(1, replytos.length) assert_equal("E1J1Rvb-0006k2-CE@localhost.localdomain", replytos[0]) assert_empty(sup_message.cc) assert_empty(sup_message.bcc) recipient_email = sup_message.recipient_email assert_equal("fake_receiver@localhost", recipient_email) message_source = sup_message.source assert_equal(message_source, source) message_source_info = sup_message.source_info assert_equal(message_source_info, source_info) # read the message body chunks chunks = sup_message.load_from_source! # there should be only one chunk assert_equal(1, chunks.length) lines = chunks.first.lines # there should be only one line assert_equal(1, lines.length) assert_equal("Test message!", lines.first) end def test_multipart_message source = DummySource.new("sup-test://test_multipart_message") source.messages = [ fixture_path('multi-part.eml') ] source_info = 0 sup_message = Message.build_from_source(source, source_info) sup_message.load_from_source! # read the message body chunks chunks = sup_message.load_from_source! # this time there should be four chunks: first the quoted part of # the message, then the non-quoted part, then the two attachments assert_equal(4, chunks.length) assert(chunks[0].is_a? Redwood::Chunk::Quote) assert(chunks[1].is_a? Redwood::Chunk::Text) assert(chunks[2].is_a? Redwood::Chunk::Attachment) assert(chunks[3].is_a? Redwood::Chunk::Attachment) # further testing of chunks will happen in test_message_chunks.rb # (possibly not yet implemented) end def test_broken_message_1 source = DummySource.new("sup-test://test_broken_message_1") source.messages = [ fixture_path('missing-from-to.eml') ] source_info = 0 sup_message = Message.build_from_source(source, source_info) sup_message.load_from_source! to = sup_message.to # there should no items, since the message doesn't have any recipients -- still not nil assert(!to.nil?) assert_empty(to) # from will have bogus values from = sup_message.from # very basic email address check assert_match(/\w+@\w+\.\w{2,4}/, from.email) refute_nil(from.name) end def test_broken_message_2 source = DummySource.new("sup-test://test_broken_message_1") source.messages = [ fixture_path('no-body.eml') ] source_info = 0 sup_message = Message.build_from_source(source, source_info) sup_message.load_from_source! # read the message body chunks: no errors should reach this level chunks = sup_message.load_from_source! assert_empty(chunks) end def test_multipart_message_2 source = DummySource.new("sup-test://test_multipart_message_2") source.messages = [ fixture_path('multi-part-2.eml') ] source_info = 0 sup_message = Message.build_from_source(source, source_info) sup_message.load_from_source! chunks = sup_message.load_from_source! # read the message body chunks assert_equal(1, chunks.length) assert(chunks[0].is_a? Redwood::Chunk::Attachment) end def test_text_attachment_decoding source = DummySource.new("sup-test://test_text_attachment_decoding") source.messages = [ fixture_path('text-attachments-with-charset.eml') ] source_info = 0 sup_message = Message.build_from_source(source, source_info) sup_message.load_from_source! chunks = sup_message.load_from_source! assert_equal(7, chunks.length) assert(chunks[0].is_a? Redwood::Chunk::Text) ## The first attachment declares charset=us-ascii assert(chunks[1].is_a? Redwood::Chunk::Attachment) assert_equal(["This is ASCII"], chunks[1].lines) ## The second attachment declares charset=koi8-r and has some Cyrillic assert(chunks[2].is_a? Redwood::Chunk::Attachment) assert_equal(["\u041f\u0440\u0438\u0432\u0435\u0442"], chunks[2].lines) ## The third attachment declares charset=utf-8 and has an emoji assert(chunks[3].is_a? Redwood::Chunk::Attachment) assert_equal(["\u{1f602}"], chunks[3].lines) ## The fourth attachment declares no charset and has a non-ASCII byte, ## which will be replaced with U+FFFD REPLACEMENT CHARACTER assert(chunks[4].is_a? Redwood::Chunk::Attachment) assert_equal(["Embedded\ufffdgarbage"], chunks[4].lines) ## The fifth attachment has an invalid charset, which should still ## be handled gracefully assert(chunks[5].is_a? Redwood::Chunk::Attachment) assert_equal(["Example invalid charset"], chunks[5].lines) ## The sixth attachment is UTF-7 encoded assert(chunks[6].is_a? Redwood::Chunk::Attachment) assert_equal(["This is ✨UTF-7✨"], chunks[6].lines) end def test_mailing_list_header source = DummySource.new("sup-test://test_mailing_list_header") source.messages = [ fixture_path('mailing-list-header.eml') ] source_info = 0 sup_message = Message.build_from_source(source, source_info) sup_message.load_from_source! assert(sup_message.list_subscribe.nil?) assert_equal("", sup_message.list_unsubscribe) assert_equal("openembedded-devel@lists.openembedded.org", sup_message.list_address.email) assert_equal("openembedded-devel", sup_message.list_address.name) end def test_blank_header_lines source = DummySource.new("sup-test://test_blank_header_lines") source.messages = [ fixture_path('blank-header-fields.eml') ] source_info = 0 sup_message = Message.build_from_source(source, source_info) sup_message.load_from_source! # See how well parsing the message ID went. id = sup_message.id assert_equal("D3C12B2AD838B44DA9D6B2CA334246D011E72A73A4@PA-EXMBX04.widget.com", id) # Look at another header field whose first line was blank. list_unsubscribe = sup_message.list_unsubscribe assert_equal(",\n\t" + "", list_unsubscribe) end def test_rfc2047_header_encoding source = DummySource.new("sup-test://test_rfc2047_header_encoding") source.messages = [ fixture_path("rfc2047-header-encoding.eml") ] source_info = 0 sup_message = Message.build_from_source(source, source_info) sup_message.load_from_source! assert_equal("Hans Martin Djupvik, Ingrid Bø, Ирина Сидорова, " + "Jesper Berg, Frida Engø " + "bad: =?UTF16?q?badcharsetname?==?US-ASCII?b?/w?=" + "=?UTF-7?Q?=41=6D=65=72=69=63=61=E2=80=99=73?=", sup_message.subj) end def test_nonascii_header ## Spammers sometimes send invalid high bytes in the headers. ## They will be replaced with U+FFFD REPLACEMENT CHARACTER. source = DummySource.new("sup-test://test_nonascii_header") source.messages = [ fixture_path("non-ascii-header.eml") ] source_info = 0 sup_message = Message.build_from_source(source, source_info) sup_message.load_from_source! assert_equal("SPAM \ufffd", sup_message.from.name) assert_equal("spammer@example.com", sup_message.from.email) assert_equal("spam \ufffd spam", sup_message.subj) end def test_utf8_header ## UTF-8 is allowed in header values according to RFC6532. source = DummySource.new("sup-test://test_utf8_header") source.messages = [ fixture_path("utf8-header.eml") ] source_info = 0 sup_message = Message.build_from_source(source, source_info) sup_message.load_from_source! assert_equal(Encoding::UTF_8, sup_message.subj.encoding) assert_equal("LibraryThing: State of the Thing — January", sup_message.subj) end def test_nonascii_header_in_nested_message source = DummySource.new("sup-test://test_nonascii_header_in_nested_message") source.messages = [ fixture_path("non-ascii-header-in-nested-message.eml") ] source_info = 0 sup_message = Message.build_from_source(source, source_info) chunks = sup_message.load_from_source! assert_equal(3, chunks.length) assert(chunks[0].is_a? Redwood::Chunk::Text) assert(chunks[1].is_a? Redwood::Chunk::EnclosedMessage) assert_equal(4, chunks[1].lines.length) assert_equal("From: SPAM \ufffd ", chunks[1].lines[0]) assert_equal("To: enclosed ", chunks[1].lines[1]) assert_equal("Subject: spam \ufffd spam", chunks[1].lines[3]) assert(chunks[2].is_a? Redwood::Chunk::Text) assert_equal(1, chunks[2].lines.length) assert_equal("This is a spam.", chunks[2].lines[0]) end def test_embedded_message source = DummySource.new("sup-test://test_embedded_message") source.messages = [ fixture_path("embedded-message.eml") ] source_info = 0 sup_message = Message.build_from_source(source, source_info) chunks = sup_message.load_from_source! assert_equal(3, chunks.length) assert_equal("sender@example.com", sup_message.from.email) assert_equal("Sender", sup_message.from.name) assert_equal(1, sup_message.to.length) assert_equal("recipient@example.invalid", sup_message.to[0].email) assert_equal("recipient", sup_message.to[0].name) assert_equal("Email with embedded message", sup_message.subj) assert(chunks[0].is_a? Redwood::Chunk::Text) assert_equal("Example outer message.", chunks[0].lines[0]) assert_equal("Example second line.", chunks[0].lines[1]) assert(chunks[1].is_a? Redwood::Chunk::EnclosedMessage) assert_equal(4, chunks[1].lines.length) assert_equal("From: Embed sender ", chunks[1].lines[0]) assert_equal("To: rcpt2 ", chunks[1].lines[1]) assert_equal("Date: ", chunks[1].lines[2][0..5]) assert_equal( Time.rfc2822("Wed, 15 Jul 2020 12:34:56 +0000"), Time.rfc2822(chunks[1].lines[2][6..-1]) ) assert_equal("Subject: Embedded subject line", chunks[1].lines[3]) assert(chunks[2].is_a? Redwood::Chunk::Text) assert_equal(2, chunks[2].lines.length) assert_equal("Example embedded message.", chunks[2].lines[0]) assert_equal("Second line.", chunks[2].lines[1]) end def test_malicious_attachment_names source = DummySource.new("sup-test://test_blank_header_lines") source.messages = [ fixture_path('malicious-attachment-names.eml') ] source_info = 0 sup_message = Message.build_from_source(source, source_info) chunks = sup_message.load_from_source! # See if attachment filenames can be safely used for saving. # We do that by verifying that any folder-related character (/ or \) # are not interpreted: the filename must not be interpreted into a # path. fn = chunks[3].safe_filename assert_equal(fn, File.basename(fn)) end # TODO: test different error cases, malformed messages etc. # TODO: test different quoting styles, see that they are all divided # to chunks properly def test_zimbra_quote_with_bottom_post # Zimbra does an Outlook-style "Original Message" delimiter and then *also* # prefixes each quoted line with a > marker. That's okay until the sender # tries to do the right thing and reply after the quote. # In this case we want to just look at the > markers when determining where # the quoted chunk ends. source = DummySource.new("sup-test://test_zimbra_quote_with_bottom_post") source.messages = [ fixture_path('zimbra-quote-with-bottom-post.eml') ] source_info = 0 sup_message = Message.build_from_source(source, source_info) chunks = sup_message.load_from_source! assert_equal(3, chunks.length) # TODO this chunk should ideally be part of the quote chunk after it. assert(chunks[0].is_a? Redwood::Chunk::Text) assert_equal(1, chunks[0].lines.length) assert_equal("----- Original Message -----", chunks[0].lines.first) assert(chunks[1].is_a? Redwood::Chunk::Quote) assert(chunks[2].is_a? Redwood::Chunk::Text) assert_equal(3, chunks[2].lines.length) assert_equal("This is the reply from the Zimbra user.", chunks[2].lines[2]) end end end # vim:noai:ts=2:sw=2: sup-1.1/test/dummy_source.rb0000644000004100000410000000207614246427237016251 0ustar www-datawww-data#!/usr/bin/ruby require 'sup' require 'stringio' require 'rmail' require 'uri' module Redwood class DummySource < Source attr_accessor :messages def initialize uri, last_date=nil, usual=true, archived=false, id=nil, labels=[] super uri, usual, archived, id @messages = nil end def start_offset 0 end def end_offset # should contain the number of test messages -1 return @messages ? @messages.length - 1 : 0 end def with_file_for id fn = @messages[id] File.open(fn, 'rb') { |f| yield f } end def load_header id with_file_for(id) { |f| parse_raw_email_header f } end def load_message id with_file_for(id) { |f| RMail::Parser.read f } end def raw_header id ret = "" with_file_for(id) do |f| until f.eof? || (l = f.gets) =~ /^$/ ret += l end end ret end def raw_message id with_file_for(id) { |f| f.read } end def each_raw_message_line id with_file_for(id) do |f| until f.eof? yield f.gets end end end end end # vim:noai:ts=2:sw=2: sup-1.1/test/test_crypto.rb0000644000004100000410000001432014246427237016110 0ustar www-datawww-data# tests for sup's crypto libs # # Copyright Clint Byrum 2011. All Rights Reserved. # Copyright Sup Developers 2013. # # 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 2 # 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, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA # 02110-1301, USA. require 'test_helper' require 'sup' require 'stringio' require 'tmpdir' module Redwood class TestCryptoManager < Minitest::Test def setup @from_email = 'sup-test-1@foo.bar' @from_email_ecc = 'sup-fake-ecc@fake.fake' @to_email = 'sup-test-2@foo.bar' # Use test gnupg setup @orig_gnupghome = ENV['GNUPGHOME'] ENV['GNUPGHOME'] = File.join(File.dirname(__FILE__), 'gnupg_test_home') @path = Dir.mktmpdir Redwood::HookManager.init File.join(@path, 'hooks') am = {:default=> {name: "test", email: @from_email, alternates: [@from_email_ecc]}} Redwood::AccountManager.init am Redwood::CryptoManager.init if not CryptoManager.have_crypto? warn "No crypto set up, crypto will not be tested. Reason: #{CryptoManager.not_working_reason}" end end def teardown CryptoManager.deinstantiate! AccountManager.deinstantiate! HookManager.deinstantiate! FileUtils.rm_r @path ENV['GNUPGHOME'] = @orig_gnupghome end def test_sign if CryptoManager.have_crypto? then signed = CryptoManager.sign @from_email,@to_email,"ABCDEFG" assert_instance_of RMail::Message, signed assert_equal("multipart/signed; protocol=application/pgp-signature; micalg=pgp-sha256", signed.header["Content-Type"]) assert_equal "ABCDEFG", signed.body[0] assert signed.body[1].body.length > 0 , "signature length must be > 0" assert (signed.body[1].body.include? "-----BEGIN PGP SIGNATURE-----") , "Expecting PGP armored data" end end def test_sign_nested_parts if CryptoManager.have_crypto? then body = RMail::Message.new body.header["Content-Disposition"] = "inline" body.body = "ABCDEFG" payload = RMail::Message.new payload.header["MIME-Version"] = "1.0" payload.add_part body payload.add_part RMail::Message.make_attachment "attachment", "text/plain", nil, "attachment.txt" signed = CryptoManager.sign @from_email, @to_email, payload ## The result is a multipart/signed containing a multipart/mixed. ## There should be a MIME-Version header on the top-level ## multipart/signed message, but *not* on the enclosed ## multipart/mixed part. assert_equal 1, signed.to_s.scan(/MIME-Version:/).size end end def test_encrypt if CryptoManager.have_crypto? then encrypted = CryptoManager.encrypt @from_email, [@to_email], "ABCDEFG" assert_instance_of RMail::Message, encrypted assert (encrypted.body[1].body.include? "-----BEGIN PGP MESSAGE-----") , "Expecting PGP armored data" end end def test_sign_and_encrypt if CryptoManager.have_crypto? then encrypted = CryptoManager.sign_and_encrypt @from_email, [@to_email], "ABCDEFG" assert_instance_of RMail::Message, encrypted assert (encrypted.body[1].body.include? "-----BEGIN PGP MESSAGE-----") , "Expecting PGP armored data" end end def test_decrypt if CryptoManager.have_crypto? then encrypted = CryptoManager.encrypt @from_email, [@to_email], "ABCDEFG" assert_instance_of RMail::Message, encrypted assert_instance_of String, (encrypted.body[1].body) decrypted = CryptoManager.decrypt encrypted.body[1], true assert_instance_of Array, decrypted assert_instance_of Chunk::CryptoNotice, decrypted[0] assert_instance_of Chunk::CryptoNotice, decrypted[1] assert_instance_of RMail::Message, decrypted[2] assert_equal "ABCDEFG" , decrypted[2].body end end def test_verify if CryptoManager.have_crypto? signed = CryptoManager.sign @from_email, @to_email, "ABCDEFG" assert_instance_of RMail::Message, signed assert_instance_of String, (signed.body[1].body) CryptoManager.verify signed.body[0], signed.body[1], true end end def test_verify_unknown_keytype if CryptoManager.have_crypto? signed = CryptoManager.sign @from_email_ecc, @to_email, "ABCDEFG" assert_instance_of RMail::Message, signed assert_instance_of String, (signed.body[1].body) CryptoManager.verify signed.body[0], signed.body[1], true end end def test_verify_nested_parts if CryptoManager.have_crypto? ## Generate a multipart/signed containing a multipart/mixed. ## We will test verifying the generated signature below. ## Importantly, the inner multipart/mixed does *not* have a ## MIME-Version header because it is not a top-level message. payload = RMail::Parser.read < 0), "The length of this line should greater than 0: #{badline}" end end end # vim:noai:ts=2:sw=2: sup-1.1/test/test_yaml_regressions.rb0000644000004100000410000000055314246427237020160 0ustar www-datawww-datarequire 'test_helper' # Requiring 'yaml' before 'sup' in 1.9.x would get Psych loaded first # and becoming the default yamler. require 'yaml' require 'sup' module Redwood class TestYamlRegressions < ::Minitest::Test def test_yamling_hash hsh = {:foo => 42} reloaded = YAML.load(hsh.to_yaml) assert_equal reloaded, hsh end end end sup-1.1/test/unit/0000755000004100000410000000000014246427237014163 5ustar www-datawww-datasup-1.1/test/unit/util/0000755000004100000410000000000014246427237015140 5ustar www-datawww-datasup-1.1/test/unit/util/test_string.rb0000644000004100000410000000312514246427237020033 0ustar www-datawww-data# encoding: utf-8 require "test_helper" require "sup/util" describe "Sup's String extension" do describe "#display_length" do let :data do [ ['some words', 10,], ['中文', 4,], ['ä', 1,], ['😱', 2], #['🏳️‍🌈', 2], # Emoji ZWJ sequence not yet supported (see PR #563) ] end it "calculates display length of a string" do data.each do |(str, length)| assert_equal length, str.display_length end end end describe "#slice_by_display_length(len)" do let :data do [ ['some words', 6, 'some w'], ['中文', 2, '中'], ['älpha', 3, 'älp'], ['😱😱', 2, '😱'], #['🏳️‍🌈', 2, '🏳️‍🌈'], # Emoji ZWJ sequence not yet supported (see PR #563) ] end it "slices string by display length" do data.each do |(str, length, sliced)| assert_equal sliced, str.slice_by_display_length(length) end end end describe "#wrap" do let :data do [ ['some words', 6, ['some', 'words']], ['some words', 80, ['some words']], ['中文', 2, ['中', '文']], ['中文', 5, ['中文']], ['älpha', 3, ['älp', 'ha']], ['😱😱', 2, ['😱', '😱']], #['🏳️‍🌈🏳️‍🌈', 2, ['🏳️‍🌈', '🏳️‍🌈']], # Emoji ZWJ sequence not yet supported (see PR #563) ] end it "wraps string by display length" do data.each do |(str, length, wrapped)| assert_equal wrapped, str.wrap(length) end end end end sup-1.1/test/unit/util/test_query.rb0000644000004100000410000000253714246427237017700 0ustar www-datawww-data# encoding: utf-8 require "test_helper" require "sup/util/query" require "xapian" describe Redwood::Util::Query do describe ".describe" do it "returns a UTF-8 description of query" do query = Xapian::Query.new "テスト" life = "生活: " assert_raises Encoding::CompatibilityError do _ = life + query.description end desc = Redwood::Util::Query.describe(query) _ = (life + desc) # No exception thrown end it "returns a valid UTF-8 description of bad input" do msg = "asdfa \xc3\x28 åasdf" query = Xapian::Query.new msg life = 'hæi' if query.description.force_encoding("UTF-8").valid_encoding? # xapian 1.4 internally handles this bad input assert true else # xapian 1.2 doesn't handle this bad input, so we do assert_raises Redwood::Util::Query::QueryDescriptionError do _desc = Redwood::Util::Query.describe (query) end end assert_raises Encoding::CompatibilityError do _ = life + query.description end end it "returns a valid UTF-8 fallback description of bad input" do msg = "asdfa \xc3\x28 åasdf" query = Xapian::Query.new msg desc = Redwood::Util::Query.describe(query, "invalid query") assert desc.force_encoding("UTF-8").valid_encoding? end end end sup-1.1/test/unit/util/test_uri.rb0000644000004100000410000000101514246427237017320 0ustar www-datawww-datarequire "test_helper.rb" require "sup/util/uri" describe Redwood::Util::Uri do describe ".build" do it "builds uri from hash" do components = {:path => "/var/mail/foo", :scheme => "mbox"} uri = Redwood::Util::Uri.build(components) assert_equal "mbox:/var/mail/foo", uri.to_s end it "expands ~ in path" do components = {:path => "~/foo", :scheme => "maildir"} uri = Redwood::Util::Uri.build(components) assert_equal "maildir:#{ENV["HOME"]}/foo", uri.to_s end end end sup-1.1/test/unit/test_horizontal_selector.rb0000644000004100000410000000171614246427237021645 0ustar www-datawww-datarequire "test_helper" require "sup/horizontal_selector" describe Redwood::HorizontalSelector do let(:values) { %w[foo@example.com bar@example.com] } let(:strange_value) { "strange@example.com" } before do @selector = Redwood::HorizontalSelector.new( 'Acc:', values, []) end it "init w/ the first value selected" do first_value = values.first assert_equal first_value, @selector.val end it "stores value for selection" do second_value = values[1] @selector.set_to second_value assert_equal second_value, @selector.val end describe "for unknown value" do it "cannot select unknown value" do assert_equal false, @selector.can_set_to?(strange_value) end it "refuses selecting unknown value" do old_value = @selector.val assert_raises Redwood::HorizontalSelector::UnknownValue do @selector.set_to strange_value end assert_equal old_value, @selector.val end end end sup-1.1/test/unit/service/0000755000004100000410000000000014246427237015623 5ustar www-datawww-datasup-1.1/test/unit/service/test_label_service.rb0000644000004100000410000000100414246427237022001 0ustar www-datawww-datarequire "test_helper" require "sup/service/label_service" describe Redwood::LabelService do describe "#add_labels" do it "add labels to all messages matching the query" do q = 'is:starred' label = 'superstarred' message = mock!.add_label(label).subject index = mock!.find_messages(q){ [message] }.subject mock(index).update_message_state(message) mock(index).save_index service = Redwood::LabelService.new(index) service.add_labels q, label end end end sup-1.1/test/unit/test_locale_fiddler.rb0000644000004100000410000000055614246427237020505 0ustar www-datawww-datarequire 'test_helper' require 'sup/util/locale_fiddler' class TestFiddle < Minitest::Test # TODO this is a silly test def test_fiddle_set_locale before = LocaleDummy.setlocale(6, nil).to_s after = LocaleDummy.setlocale(6, "").to_s assert(before != after, "Expected locale to be fiddled with") end end class LocaleDummy extend LocaleFiddler end sup-1.1/test/unit/test_person.rb0000644000004100000410000000161214246427237017055 0ustar www-datawww-datarequire 'test_helper' require 'sup' module Redwood class TestPerson < Minitest::Test def setup @person = Person.new("Thomassen, Bob", "bob@thomassen.com") @no_name = Person.new(nil, "alice@alice.com") end def test_email_must_be_supplied assert_raises (ArgumentError) { Person.new("Alice", nil) } end def test_to_string assert_equal "Thomassen, Bob ", "#{@person}" assert_equal "alice@alice.com", "#{@no_name}" end def test_shortname assert_equal "Bob", @person.shortname assert_equal "alice@alice.com", @no_name.shortname end def test_mediumname assert_equal "Thomassen, Bob", @person.mediumname assert_equal "alice@alice.com", @no_name.mediumname end def test_fullname assert_equal "\"Thomassen, Bob\" ", @person.full_address assert_equal "alice@alice.com", @no_name.full_address end end endsup-1.1/test/unit/test_contact.rb0000644000004100000410000000150414246427237017202 0ustar www-datawww-datarequire 'test_helper' require 'sup/contact' module Redwood class TestContact < Minitest::Test def setup @contact = ContactManager.init(File.expand_path("../../fixtures/contacts.txt", __FILE__)) @person = Person.new "Terrible Name", "terrible@name.com" end def teardown runner = Redwood.const_get "ContactManager".to_sym runner.deinstantiate! end def test_contact_manager assert @contact ## 1 contact is imported from the fixture file. assert_equal 1, @contact.contacts.count assert_equal @contact.contact_for("RC").name, "Random Contact" assert_nil @contact.contact_for "TN" @contact.update_alias @person, "TN" assert @contact.is_aliased_contact?(@person) assert_equal @person, @contact.contact_for("TN") assert_equal "TN", @contact.alias_for(@person) end end endsup-1.1/test/test_header_parsing.rb0000644000004100000410000000754614246427237017557 0ustar www-datawww-data#!/usr/bin/ruby require 'test_helper' require 'sup' require 'stringio' include Redwood class TestMBoxParsing < Minitest::Test def setup @path = Dir.mktmpdir @mbox = File.join(@path, 'test_mbox') @log = StringIO.new Redwood::Logger.add_sink @log Redwood::Logger.remove_sink $stderr end def teardown Redwood::Logger.clear! Redwood::Logger.remove_sink @log Redwood::Logger.add_sink $stderr FileUtils.rm_r @path end def test_normal_headers h = Source.parse_raw_email_header StringIO.new(< To: Sally EOS assert_equal "Bob ", h["from"] assert_equal "Sally ", h["to"] assert_nil h["message-id"] end def test_multiline h = Source.parse_raw_email_header StringIO.new(< Subject: one two three four five six To: Sally References: Seven: Eight EOS assert_equal "one two three four five six", h["subject"] assert_equal "Sally ", h["to"] assert_equal " ", h["references"] end def test_ignore_spacing variants = [ "Subject:one two three end\n", "Subject: one two three end\n", "Subject: one two three end \n", ] variants.each do |s| h = Source.parse_raw_email_header StringIO.new(s) assert_equal "one two three end", h["subject"] end end def test_message_id_ignore_spacing variants = [ "Message-Id: \n", "Message-Id: \n", ] variants.each do |s| h = Source.parse_raw_email_header StringIO.new(s) assert_equal "", h["message-id"] end end def test_blank_lines h = Source.parse_raw_email_header StringIO.new("") assert_nil h["message-id"] end def test_empty_headers variants = [ "Message-Id: \n", "Message-Id:\n", ] variants.each do |s| h = Source.parse_raw_email_header StringIO.new(s) assert_equal "", h["message-id"] end end def test_detect_end_of_headers h = Source.parse_raw_email_header StringIO.new(< To: a dear friend EOS assert_equal "Bob ", h["from"] assert_nil h["to"] h = Source.parse_raw_email_header StringIO.new(< \r To: a dear friend EOS assert_equal "Bob ", h["from"] assert_nil h["to"] h = Source.parse_raw_email_header StringIO.new(< \r\n\r To: a dear friend EOS assert_equal "Bob ", h["from"] assert_nil h["to"] end def test_from_line_splitting l = MBox.new mbox_for_string(< To: a dear friend Hello there friend. How are you? From sea to shining sea From bob@bob.com I get only spam. From bob@bob.com From bob@bob.com (that second one has spaces at the endj This is the end of the email. EOS offset = l.next_offset 0 assert_equal 61, offset offset = l.next_offset 61 assert_nil offset assert_match(/WARNING: found invalid date in potential mbox split line, not splitting/, @log.string) end def test_more_from_line_splitting l = MBox.new mbox_for_string(< To: a dear friend Hello there friend. How are you? From bob@bob.com Mon Apr 27 12:56:19 2009 From: Bob To: a dear friend Hello again! Would you like to buy my products? EOS offset = l.next_offset 0 refute_nil offset offset = l.next_offset offset refute_nil offset offset = l.next_offset offset assert_nil offset end def mbox_for_string content File.open(@mbox, 'w') do |f| f.write content end "mbox://#{@mbox}" end end sup-1.1/test/integration/0000755000004100000410000000000014246427237015527 5ustar www-datawww-datasup-1.1/test/integration/test_maildir.rb0000644000004100000410000000371014246427237020535 0ustar www-datawww-datarequire "test_helper" class TestMaildir < Minitest::Test def setup @path = Dir.mktmpdir @test_message_1 = < To: a dear friend Hello there friend. How are you? Blah is blah blah. Wow. Maildir FTW, am I right? EOS end def teardown ObjectSpace.each_object(Class).select {|a| a < Redwood::Singleton}.each do |klass| klass.deinstantiate! unless klass == Redwood::Logger end FileUtils.rm_r @path end def create_a_maildir(extra='') maildir = File.join @path, "test_maildir#{extra}" ['', 'cur', 'new', 'tmp'].each do |dir| Dir.mkdir(File.join maildir, dir) end maildir end def create_a_maildir_email(folder, content) File.write(File.join(folder, "#{Time.now.to_f}.hostname:2,S"), content) end def start_sup_and_add_source(source) start Index.init @path Index.load SourceManager.instance.instance_eval '@sources = {}' SourceManager.instance.add_source source PollManager.poll_from source end # and now, let the tests begin! def test_can_index_a_maildir_directory maildir = create_a_maildir create_a_maildir_email(File.join(maildir, 'cur'), @test_message_1) start_sup_and_add_source Maildir.new "maildir:#{maildir}" messages_in_index = [] Index.instance.each_message {|a| messages_in_index << a} refute_empty messages_in_index, 'There are no messages in the index' assert_equal(messages_in_index.first.raw_message, @test_message_1) end def test_can_index_a_maildir_directory_with_special_characters maildir = create_a_maildir URI_ENCODE_CHARS create_a_maildir_email(File.join(maildir, 'cur'), @test_message_1) start_sup_and_add_source Maildir.new "maildir:#{maildir}" messages_in_index = [] Index.instance.each_message {|a| messages_in_index << a} refute_empty messages_in_index, 'There are no messages in the index' assert_equal(messages_in_index.first.raw_message, @test_message_1) end end sup-1.1/test/integration/test_mbox.rb0000644000004100000410000000347614246427237020072 0ustar www-datawww-datarequire "test_helper" class TestMbox < Minitest::Test def setup @path = Dir.mktmpdir @test_message_1 = < To: Joe Hello there friend. How are you? Blah is blah blah. I like mboxes, don't you? EOS end def teardown ObjectSpace.each_object(Class).select {|a| a < Redwood::Singleton}.each do |klass| klass.deinstantiate! unless klass == Redwood::Logger end FileUtils.rm_r @path end def create_a_mbox(extra='') mbox = File.join(@path, "test_mbox#{extra}.mbox") File.write(mbox, @test_message_1) mbox end def start_sup_and_add_source(source) start Index.init @path Index.load SourceManager.instance.instance_eval '@sources = {}' SourceManager.instance.add_source source PollManager.poll_from source end # and now, let the tests begin! def test_can_index_a_mbox_directory mbox = create_a_mbox start_sup_and_add_source MBox.new "mbox:#{mbox}" messages_in_index = [] Index.instance.each_message {|a| messages_in_index << a} refute_empty messages_in_index, 'There are no messages in the index' test_message_without_first_line = @test_message_1.sub(/^.*\n/,'') assert_equal(messages_in_index.first.raw_message, test_message_without_first_line) end def test_can_index_a_mbox_directory_with_special_characters mbox = create_a_mbox URI_ENCODE_CHARS start_sup_and_add_source MBox.new "mbox:#{mbox}" messages_in_index = [] Index.instance.each_message {|a| messages_in_index << a} refute_empty messages_in_index, 'There are no messages in the index' test_message_without_first_line = @test_message_1.sub(/^.*\n/,'') assert_equal(messages_in_index.first.raw_message, test_message_without_first_line) end end sup-1.1/test/integration/test_sup-add.rb0000644000004100000410000000354514246427237020457 0ustar www-datawww-dataclass TestSupAdd < Minitest::Test def setup @path = Dir.mktmpdir end def teardown FileUtils.rm_r @path end def test_can_add_maildir_source _out, _err = capture_subprocess_io do assert system({"SUP_BASE" => @path}, "bin/sup-add", "maildir:///some/path") end generated_sources_yaml = File.read "#{@path}/sources.yaml" assert_equal < uri: maildir:///some/path usual: true archived: false sync_back: true id: 1 labels: [] EOS end def test_fixes_old_tag_uri_syntax File.write "#{@path}/sources.yaml", < @path}, "bin/sup-add", "maildir:///other/path") end generated_sources_yaml = File.read "#{@path}/sources.yaml" assert_equal < uri: maildir:/some/path usual: true archived: false sync_back: true id: 1 labels: [] - ! uri: maildir:///other/path usual: true archived: false sync_back: true id: 2 labels: [] EOS end ## https://github.com/sup-heliotrope/sup/issues/545 def test_source_with_invalid_uri_chars_in_path _out, _err = capture_subprocess_io do assert system({"SUP_BASE" => @path}, "bin/sup-add", "maildir:~/.mail/gmail/[Gmail]/.All Mail/") end generated_sources_yaml = File.read "#{@path}/sources.yaml" assert_equal < uri: maildir:~/.mail/gmail/[Gmail]/.All Mail/ usual: true archived: false sync_back: true id: 1 labels: [] EOS end end sup-1.1/test/test_helper.rb0000644000004100000410000000042114246427237016044 0ustar www-datawww-datarequire "rubygems" rescue nil require 'minitest/autorun' require "rr" def fixture_path(filename) File.expand_path("../fixtures/#{filename}", __FILE__) end def fixture_contents(filename) file = '' File.open(fixture_path(filename)) { |io| file = io.read } file end sup-1.1/README.md0000644000004100000410000000762014246427237013511 0ustar www-datawww-data# Sup Sup is a console-based email client for people with a lot of email. ## Installation [See the wiki][Installation] ## Features / Problems Features: * GMail-like thread-centered archiving, tagging and muting * [Handling mail from multiple mbox and Maildir sources][sources] * Blazing fast full-text search with a [rich query language][search] * Multiple accounts - pick the right one when sending mail * [Ruby-programmable hooks][hooks] * Automatically tracking recent contacts Current limitations: * Sup does in general not play nicely with other mail clients, not all changes can be synced back to the mail source. Refer to [Maildir Syncback][maildir-syncback] in the wiki for this recently included feature. Maildir Syncback allows you to sync back flag changes in messages and to write messages to maildir sources. * Unix-centrism in MIME attachment handling and in sendmail invocation. ## Problems Please report bugs to the [GitHub issue tracker](https://github.com/sup-heliotrope/sup/issues). ## Links * [Homepage](https://sup-heliotrope.github.io/) * [Code repository](https://github.com/sup-heliotrope/sup) * [Wiki](https://github.com/sup-heliotrope/sup/wiki) * IRC: [#sup @ freenode.net](http://webchat.freenode.net/?channels=#sup) * Mailing list: supmua@googlegroups.com (subscribe: supmua+subscribe@googlegroups.com, archive: https://groups.google.com/d/forum/supmua ) ## Maintenance status Sup is a mature, production-quality mail client. The maintainers are also long-term users, and mainly focus on preserving the current feature set. Pull requests are very welcome, especially to fix bugs or improve compatibility, however pull requests for major new features are unlikely to be merged. ## Alternatives If Sup is missing a feature you are interested in, it might be possible to accomplish using Sup's [powerful hooks mechanism][hooks]. Otherwise, here are some alternatives to consider: * [Notmuch](https://notmuchmail.org/) was inspired by Sup. There are a wide variety of [Notmuch clients](https://notmuchmail.org/frontends/) available. The most similar to Sup's look-and-feel is [Alot](https://github.com/pazz/alot) — also a curses-based front end. Alot even ships with a [built-in](https://github.com/pazz/alot/blob/master/extra/themes/sup) [Sup theme](https://github.com/pazz/alot/wiki/Gallery#user-content-theme-sup)! * [mu](https://www.djcbsoftware.nl/code/mu/) / [mu4e](https://www.djcbsoftware.nl/code/mu/mu4e.html). Like Sup, a search-based email back end, and also implemented using Xapian. The emacs-based front end [is quite different](https://www.djcbsoftware.nl/code/mu/mu4e/Other-mail-clients.html). ## License ``` Copyright (c) 2013-- Sup developers. Copyright (c) 2006--2009 William Morgan. 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 2 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, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. ``` [sources]: https://github.com/sup-heliotrope/sup/wiki/Adding-sources [hooks]: https://github.com/sup-heliotrope/sup/wiki/Hooks [search]: https://github.com/sup-heliotrope/sup/wiki/Searching-your-mail [Installation]: https://github.com/sup-heliotrope/sup/wiki#installation [ruby20]: https://github.com/sup-heliotrope/sup/wiki/Development#sup-014 [maildir-syncback]: https://github.com/sup-heliotrope/sup/wiki/Using-sup-with-other-clients sup-1.1/bin/0000755000004100000410000000000014246427237012775 5ustar www-datawww-datasup-1.1/bin/sup-import-dump0000755000004100000410000000565014246427237016013 0ustar www-datawww-data#!/usr/bin/env ruby $:.unshift File.join(File.dirname(__FILE__), *%w[.. lib]) require 'uri' require 'optimist' require "sup" PROGRESS_UPDATE_INTERVAL = 15 # seconds class AbortExecution < SystemExit end opts = Optimist::options do version "sup-import-dump (sup #{Redwood::VERSION})" banner < instead. Messages not mentioned in the dump file will not be modified. Usage: sup-import-dump [options] Options: EOS opt :verbose, "Print message ids as they're processed." opt :ignore_missing, "Silently skip over messages that are not in the index." opt :warn_missing, "Warn about messages that are not in the index, but continue." opt :abort_missing, "Abort on encountering messages that are not in the index. (default)" opt :atomic, "Use transaction to apply all changes atomically." opt :dry_run, "Don't actually modify the index. Probably only useful with --verbose.", :short => "-n" opt :version, "Show version information", :short => :none conflicts :ignore_missing, :warn_missing, :abort_missing end Optimist::die "No dump file given" if ARGV.empty? Optimist::die "Extra arguments given" if ARGV.length > 1 dump_name = ARGV.shift missing_action = [:ignore_missing, :warn_missing, :abort_missing].find { |x| opts[x] } || :abort_missing Redwood::start index = Redwood::Index.init index.lock_interactively or exit begin num_read = 0 num_changed = 0 index.load index.begin_transaction if opts[:atomic] IO.foreach dump_name do |l| l =~ /^(\S+) \((.*?)\)$/ or raise "Can't read dump line: #{l.inspect}" mid, labels = $1, $2 num_read += 1 unless index.contains_id? mid if missing_action == :abort_missing $stderr.puts "Message #{mid} not found in index, aborting." raise AbortExecution, 10 elsif missing_action == :warn_missing $stderr.puts "Message #{mid} not found in index, skipping." end next end m = index.build_message mid new_labels = labels.to_set_of_symbols if m.labels == new_labels puts "#{mid} unchanged" if opts[:verbose] next end puts "Changing flags for #{mid} from '#{m.labels.to_a * ' '}' to '#{new_labels.to_a * ' '}'" if opts[:verbose] num_changed += 1 next if opts[:dry_run] m.labels = new_labels index.update_message_state [m, false] end index.commit_transaction if opts[:atomic] puts "Updated #{num_changed} of #{num_read} messages." rescue AbortExecution index.cancel_transaction if opts[:atomic] raise rescue Exception => e index.cancel_transaction if opts[:atomic] File.open("sup-exception-log.txt", "w") { |f| f.puts e.backtrace } raise ensure index.save_index unless opts[:atomic] Redwood::finish index.unlock end sup-1.1/bin/sup-sync0000755000004100000410000001564414246427237014516 0ustar www-datawww-data#!/usr/bin/env ruby $:.unshift File.join(File.dirname(__FILE__), *%w[.. lib]) require 'uri' require 'optimist' require "sup" PROGRESS_UPDATE_INTERVAL = 15 # seconds class Float def to_s; sprintf '%.2f', self; end def to_time_s; infinite? ? "unknown" : super end end class Numeric def to_time_s i = to_i sprintf "%d:%02d:%02d", i / 3600, (i / 60) % 60, i % 60 end end class Set def to_s; to_a * ',' end end def time startt = Time.now yield Time.now - startt end opts = Optimist::options do version "sup-sync (sup #{Redwood::VERSION})" banner <* where * is zero or more source URIs. If no sources are given, sync from all usual sources. Supported source URI schemes can be seen by running "sup-add --help". Options controlling HOW message state is altered: EOS opt :asis, "If the message is already in the index, preserve its state. Otherwise, use default source state. (Default.)", :short => :none opt :restore, "Restore message state from a dump file created with sup-dump. If a message is not in this dumpfile, act as --asis.", :type => String, :short => :none opt :discard, "Discard any message state in the index and use the default source state. Dangerous!", :short => :none opt :archive, "When using the default source state, mark messages as archived.", :short => "-x" opt :read, "When using the default source state, mark messages as read." opt :extra_labels, "When using the default source state, also apply these user-defined labels (a comma-separated list)", :default => "", :short => :none text < :none opt :dry_run, "Don't actually modify the index. Probably only useful with --verbose.", :short => "-n" opt :version, "Show version information", :short => :none conflicts :asis, :restore, :discard end op = [:asis, :restore, :discard].find { |x| opts[x] } || :asis Redwood::start index = Redwood::Index.init restored_state = if opts[:restore] dump = {} puts "Loading state dump from #{opts[:restore]}..." IO.foreach opts[:restore] do |l| l =~ /^(\S+) \((.*?)\)$/ or raise "Can't read dump line: #{l.inspect}" mid, labels = $1, $2 dump[mid] = labels.to_set_of_symbols end puts "Read #{dump.size} entries from dump file." dump else {} end seen = {} index.lock_interactively or exit begin index.load if(s = Redwood::SourceManager.source_for Redwood::SentManager.source_uri) Redwood::SentManager.source = s else Redwood::SourceManager.add_source Redwood::SentManager.default_source end sources = if opts[:all_sources] Redwood::SourceManager.sources elsif ARGV.empty? Redwood::SourceManager.usual_sources else ARGV.map do |uri| Redwood::SourceManager.source_for uri or Optimist::die "Unknown source: #{uri}. Did you add it with sup-add first?" end end sources.each do |source| puts "Scanning #{source}..." num_added = num_updated = num_deleted = num_scanned = num_restored = 0 last_info_time = start_time = Time.now Redwood::PollManager.poll_from source do |action,m,old_m,progress| num_scanned += 1 if action == :delete num_deleted += 1 puts "Deleting #{m.id}" if opts[:verbose] elsif action == :add seen[m.id] = true ## tweak source labels according to commandline arguments if necessary m.labels.delete :inbox if opts[:archive] m.labels.delete :unread if opts[:read] m.labels += opts[:extra_labels].to_set_of_symbols(",") ## decide what to do based on message labels and the operation we're performing dothis = case when (op == :restore) && restored_state[m.id] if old_m && (old_m.labels != restored_state[m.id]) num_restored += 1 m.labels = restored_state[m.id] :update_message_state elsif old_m.nil? num_restored += 1 m.labels = restored_state[m.id] :add_message else # labels are the same; don't do anything end when op == :discard if old_m && (old_m.labels != m.labels) :update_message_state else # labels are the same; don't do anything end else if old_m :update_message else :add_message end end ## now, actually do the operation case dothis when :add_message puts "Adding new message #{source}##{m.source_info} with labels #{m.labels}" if opts[:verbose] num_added += 1 when :update_message puts "Updating message #{source}##{m.source_info}; labels #{old_m.labels} => #{m.labels}; offset #{old_m.source_info} => #{m.source_info}" if opts[:verbose] num_updated += 1 when :update_message_state puts "Changing flags for #{source}##{m.source_info} from #{old_m.labels} to #{m.labels}" if opts[:verbose] num_updated += 1 end else fail "sup-sync cannot handle :update's" end if Time.now - last_info_time > PROGRESS_UPDATE_INTERVAL last_info_time = Time.now elapsed = last_info_time - start_time pctdone = progress * 100.0 remaining = (100.0 - pctdone) * (elapsed.to_f / pctdone) printf "## scanned %dm (~%.0f%%) @ %.1fm/s. %s elapsed, ~%s remaining\n", num_scanned, pctdone, num_scanned / elapsed, elapsed.to_time_s, remaining.to_time_s end next if opts[:dry_run] end puts "Scanned #{num_scanned}, added #{num_added}, updated #{num_updated}, deleted #{num_deleted} messages from #{source}." puts "Restored state on #{num_restored} (#{100.0 * num_restored / num_scanned}%) messages." if num_restored > 0 end index.save if opts[:optimize] puts "Optimizing index..." optt = time { index.optimize unless opts[:dry_run] } puts "Optimized index of size #{index.size} in #{optt}s." end rescue Redwood::FatalSourceError => e $stderr.puts "Sorry, I couldn't communicate with a source: #{e.message}" rescue Exception => e File.open("sup-exception-log.txt", "w") { |f| f.puts e.backtrace } raise ensure Redwood::finish index.unlock end sup-1.1/bin/sup-config0000755000004100000410000001515514246427237015004 0ustar www-datawww-data#!/usr/bin/env ruby $:.unshift File.join(File.dirname(__FILE__), *%w[.. lib]) require 'optimist' require "sup" require 'sup/util/axe' $opts = Optimist::options do version "sup-config (sup #{Redwood::VERSION})" banner < "mbox", :path => fn }] when :maildir $last_fn ||= ENV["MAIL"] fn = axe "What's the full path to the maildir directory?", $last_fn return if fn.nil? || fn.empty? $last_fn = fn [Redwood::Maildir.suggest_labels_for(fn), { :scheme => "maildir", :path => fn }] end uri = begin Redwood::Util::Uri.build components rescue URI::Error => e @cli.say "Whoopsie! I couldn't build a URI from that: #{e.message}" if axe_yes("Try again?") then next else return end end @cli.say "I'm going to add this source: #{uri}" unless axe("Does that look right?", "y") =~ /^y|yes$/i if axe_yes("Try again?") then next else return end end usual = axe_yes "Does this source ever receive new messages?", "y" archive = usual ? axe_yes("Should new messages be automatically archived? (I.e. not appear in your inbox, though still be accessible via search.)") : false sync_back = (type == :maildir) ? axe_yes("Should the original Maildir messages be modified to reflect changes like read status, starred messages, etc.?", "y") : false labels_str = axe("Enter any labels to be automatically added to all messages from this source, separated by spaces (or 'none')", default_labels.join(",")) labels = if labels_str =~ /^\s*none\s*$/i nil else labels_str.split(/\s+/) end cmd = build_cmd "sup-add" cmd += " --unusual" unless usual cmd += " --archive" if archive cmd += " --no-sync-back" unless sync_back cmd += " --labels=#{labels.join(',')}" if labels && !labels.empty? cmd += " #{uri}" puts "Ok, trying to run \"#{cmd}\"..." system cmd if $?.success? @cli.say "Great! Added!" break else @cli.say "Rats, that failed. You may have to do it manually." if axe_yes("Try again?") then next else return end end end end @cli.wrap_at = :auto Redwood::start index = Redwood::Index.init Redwood::SourceManager.load_sources @cli.say <" @cli.say "\nDo you have any alternate email addresses that also receive email?" @cli.say "If so, enter them now, separated by spaces." alts = axe("Alternate email addresses", account[:alternates].join(" ")).split(/\s+/) sigfn = axe "What file contains your signature?", account[:signature] editor = axe "What editor would you like to use?", $config[:editor] time_mode = axe "Would like to display time in 12h (type 12h) or in 24h (type 24h)?", $config[:time_mode] $config[:accounts][:default][:name] = name $config[:accounts][:default][:email] = email $config[:accounts][:default][:alternates] = alts $config[:accounts][:default][:signature] = sigfn $config[:editor] = editor $config[:time_mode] = time_mode done = false until done @cli.say "\nNow, we'll tell Sup where to find all your email." Redwood::SourceManager.load_sources @cli.say "Current sources:" if Redwood::SourceManager.sources.empty? @cli.say " No sources!" else Redwood::SourceManager.sources.each { |s| puts "* #{s}" } end @cli.say "\n" @cli.choose do |menu| menu.prompt = "Your wish? " menu.choice("Add a new source.") { add_source } menu.choice("Done adding sources!") { done = true } end end @cli.say "\nSup needs to know where to store your sent messages." @cli.say "Only sources capable of storing mail will be listed.\n\n" Redwood::SourceManager.load_sources if Redwood::SourceManager.sources.empty? @cli.say "\nUsing the default sup://sent, since you haven't configured other sources yet." $config[:sent_source] = 'sup://sent' else # this handles the event that source.yaml already contains the SentLoader # source. have_sup_sent = false @cli.choose do |menu| menu.prompt = "Store my sent mail in? " menu.choice('Default (an mbox in ~/.sup, aka sup://sent)') { $config[:sent_source] = 'sup://sent'} unless have_sup_sent valid_sents = Redwood::SourceManager.sources.each do |s| have_sup_sent = true if s.to_s.eql?('sup://sent') menu.choice(s.to_s) { $config[:sent_source] = s.to_s } if s.respond_to? :store_message end end end Redwood::save_yaml_obj $config, Redwood::CONFIG_FN, false, true @cli.say "Ok, I've saved you up a nice lil' #{Redwood::CONFIG_FN}." @cli.say <* where * is source URIs. If no source is given, the default behavior is to sync back all Maildir sources marked as usual and that have not disabled sync back using the configuration parameter sync_back = false in sources.yaml. Options include: EOS opt :no_confirm, "Don't ask for confirmation before synchronizing", :default => false, :short => "n" opt :no_merge, "Don't merge new supported Maildir flags (R and P)", :default => false, :short => "m" opt :list_sources, "List your Maildir sources and exit", :default => false, :short => "l" opt :unusual_sources_too, "Sync unusual sources too if no specific source information is given", :default => false, :short => "u" end def die msg $stderr.puts "Error: #{msg}" exit(-1) end Redwood::start true index = Redwood::Index.init index.lock_interactively or exit index.load ## Force sync_back_to_maildir option otherwise nothing will happen $config[:sync_back_to_maildir] = true begin sync_performed = [] sync_performed = File.readlines(Redwood::SYNC_OK_FN).collect { |e| e.strip }.find_all { |e| not e.empty? } if File.exist? Redwood::SYNC_OK_FN sources = [] ## Try to find out sources given in parameters sources = ARGV.map do |uri| s = Redwood::SourceManager.source_for(uri) or die "unknown source: #{uri}. Did you add it with sup-add first?" s.is_a?(Redwood::Maildir) or die "#{uri} is not a Maildir source." s.sync_back_enabled? or die "#{uri} has disabled sync back - check your configuration." s end unless opts[:list_sources] ## Otherwise, check all sources in sources.yaml if sources.empty? or opts[:list_sources] == true if opts[:unusual_sources_too] sources = Redwood::SourceManager.sources.select do |s| s.is_a? Redwood::Maildir and s.sync_back_enabled? end else sources = Redwood::SourceManager.usual_sources.select do |s| s.is_a? Redwood::Maildir and s.sync_back_enabled? end end end if opts[:list_sources] == true sources.each do |s| puts "id: #{s.id}, uri: #{s.uri}" end else sources.each do |s| if opts[:no_confirm] == false print "Are you sure you want to synchronize '#{s.uri}'? (Y/n) " next if STDIN.gets.chomp.downcase == 'n' end infos = Enumerator.new(index, :each_source_info, s.id).to_a counter = 0 infos.each do |info| print "\rSynchronizing '#{s.uri}'... #{((counter += 1)/infos.size.to_f*100).to_i}%" index.each_message({:location => [s.id, info]}, false) do |m| if opts[:no_merge] == false m.merge_labels_from_locations [:replied, :forwarded] end if Redwood::Index.message_joining_killed? m m.labels += [:killed] end index.save_message m end end print "\n" sync_performed << s.uri end ## Write a flag file to tell sup that the synchronization has been performed File.open(Redwood::SYNC_OK_FN, 'w') {|f| f.write(sync_performed.join("\n")) } end rescue Exception => e File.open("sup-exception-log.txt", "w") { |f| f.puts e.backtrace } raise ensure index.save_index Redwood::finish index.unlock end sup-1.1/bin/sup-recover-sources0000755000004100000410000000516114246427237016661 0ustar www-datawww-data#!/usr/bin/env ruby $:.unshift File.join(File.dirname(__FILE__), *%w[.. lib]) require 'optparse' $opts = { :unusual => false, :archive => false, :scan_num => 10, } OPTIONPARSERSUCKS = "\n" + " " * 38 OptionParser.new do |opts| opts.banner = <+ Rebuilds a lost sources.yaml file by reading messages from a list of sources and determining, for each source, the most prevalent 'source_id' field of messages from that source in the index. The only non-deterministic component to this is that if the same message appears in multiple sources, those sources may be mis-diagnosed by this program. If the first N messages (--scan-num below) all have the same source_id in the index, the source will be added to sources.yaml. Otherwise, the distribution will be printed, and you will have to add it by hand. The offset pointer into the sources will be set to the end of the source, so you will have to run sup-import --rebuild for each new source after doing this. Options include: EOS opts.on("--unusual", "Mark sources as 'unusual'. Only usual#{OPTIONPARSERSUCKS}sources will be polled by hand. Default:#{OPTIONPARSERSUCKS}#{$opts[:unusual]}.") { $opts[:unusual] = true } opts.on("--archive", "Mark sources as 'archive'. New messages#{OPTIONPARSERSUCKS}from these sources will not appear in#{OPTIONPARSERSUCKS}the inbox. Default: #{$opts[:archive]}.") { $opts[:archive] = true } opts.on("--scan-num N", Integer, "Number of messages to scan per source.#{OPTIONPARSERSUCKS}Default: #{$opts[:scan_num]}.") do |n| $opts[:scan_num] = n end opts.on_tail("-h", "--help", "Show this message") do puts opts exit end end.parse(ARGV) require "sup" Redwood::start puts "loading index..." index = Redwood::Index.init index.load puts "loaded index of #{index.size} messages" ARGV.each do |fn| next if Redwood::SourceManager.source_for fn ## TODO: merge this code with the same snippet in import source = Redwood::MBox.new(fn, nil, !$opts[:unusual], $opts[:archive]) source_ids = Hash.new 0 count = 0 source.each do |offset, labels| m = Redwood::Message.new :source => source, :source_info => offset m.load_from_source! source_id = Redwood::SourceManager.source_for_id m.id next unless source_id source_ids[source_id] += 1 count += 1 break if count == $opts[:scan_num] end if source_ids.size == 1 id = source_ids.keys.first.to_i puts "assigned #{source} to #{source_ids.keys.first}" source.id = id Redwood::SourceManager.add_source source else puts ">> unable to determine #{source}: #{source_ids.inspect}" end end index.save sup-1.1/bin/sup-tweak-labels0000755000004100000410000001023514246427237016104 0ustar www-datawww-data#!/usr/bin/env ruby $:.unshift File.join(File.dirname(__FILE__), *%w[.. lib]) require 'optimist' require "sup" class Float def to_s; sprintf '%.2f', self; end def to_time_s infinite? ? "unknown" : super end end class Numeric def to_time_s i = to_i sprintf "%d:%02d:%02d", i / 3600, (i / 60) % 60, i % 60 end end def time startt = Time.now yield Time.now - startt end opts = Optimist::options do version "sup-tweak-labels (sup #{Redwood::VERSION})" banner <* where * is zero or more source URIs. Supported source URI schemes can be seen by running "sup-add --help". Options: EOS opt :add, "One or more labels (comma-separated) to add to every message from the specified sources", :default => "" opt :remove, "One or more labels (comma-separated) to remove from every message from the specified sources, if those labels are present", :default => "" opt :query, "A Sup search query", :type => String text < :none opt :dry_run, "Don't actually modify the index. Probably only useful with --verbose.", :short => "-n" opt :no_sync_back, "Do not sync back to the original Maildir." opt :version, "Show version information", :short => :none end opts[:verbose] = true if opts[:very_verbose] add_labels = opts[:add].to_set_of_symbols "," remove_labels = opts[:remove].to_set_of_symbols "," Optimist::die "nothing to do: no labels to add or remove" if add_labels.empty? && remove_labels.empty? Redwood::start index = Redwood::Index.init index.lock_interactively or exit begin index.load source_ids = if opts[:all_sources] Redwood::SourceManager.sources else ARGV.map do |uri| Redwood::SourceManager.source_for uri or Optimist::die "Unknown source: #{uri}. Did you add it with sup-add first?" end end.map { |s| s.id } Optimist::die "nothing to do: no sources" if source_ids.empty? query = "(" + source_ids.map { |id| "source_id:#{id}" }.join(" OR ") + ")" if add_labels.empty? ## if all we're doing is removing labels, we can further restrict the ## query to only messages with those labels query += " (" + remove_labels.map { |l| "label:#{l}" }.join(" OR ") + ")" end query += ' AND ' + opts[:query] if opts[:query] parsed_query = index.parse_query query parsed_query.merge! :load_spam => true, :load_deleted => true, :load_killed => true ids = index.to_enum(:each_id, parsed_query) num_total = index.num_results_for parsed_query $stderr.puts "Found #{num_total} documents across #{source_ids.length} sources. Scanning..." num_changed = num_scanned = 0 last_info_time = start_time = Time.now ids.each do |id| num_scanned += 1 m = index.build_message id old_labels = m.labels.dup m.labels += add_labels m.labels -= remove_labels unless m.labels == old_labels num_changed += 1 puts "From #{m.from}, subject: #{m.subj}" if opts[:very_verbose] puts "#{m.id}: {#{old_labels.to_a.join ','}} => {#{m.labels.to_a.join ','}}" if opts[:verbose] puts if opts[:very_verbose] unless opts[:dry_run] index.update_message_state [m, false] m.sync_back unless opts[:no_sync_back] end end if Time.now - last_info_time > 60 last_info_time = Time.now elapsed = last_info_time - start_time pctdone = 100.0 * num_scanned.to_f / num_total.to_f remaining = (100.0 - pctdone) * (elapsed.to_f / pctdone) $stderr.puts "## #{num_scanned} (#{pctdone}%) read; #{elapsed.to_time_s} elapsed; #{remaining.to_time_s} remaining" end end $stderr.puts "Scanned #{num_scanned} / #{num_total} messages and changed #{num_changed}." unless num_changed == 0 $stderr.puts "Optimizing index..." index.optimize unless opts[:dry_run] end rescue Exception => e File.open("sup-exception-log.txt", "w") { |f| f.puts e.backtrace } raise ensure index.save Redwood::finish index.unlock end sup-1.1/bin/sup-dump0000755000004100000410000000210114246427237014467 0ustar www-datawww-data#!/usr/bin/env ruby $:.unshift File.join(File.dirname(__FILE__), *%w[.. lib]) require 'xapian' require 'optimist' require 'set' BASE_DIR = ENV["SUP_BASE"] || File.join(ENV["HOME"], ".sup") $opts = Optimist::options do version "sup-dump" banner < to recover the index. This tool is primarily useful in the event that a Sup upgrade breaks index format compatibility. Usage: sup-dump > sup-dump | bzip2 > # even better EOS end xapian = Xapian::Database.new File.join(BASE_DIR, 'xapian') version = xapian.get_metadata 'rescue-version' version = '0' if version.empty? case version when '0' xapian.postlist('Kmail').each do |x| begin entry = Marshal.load(xapian.document(x.docid).data) puts "#{entry[:message_id]} (#{entry[:labels].sort_by { |l| l.to_s } * ' '})" rescue $stderr.puts "failed to dump document #{x.docid}" end end else abort "this sup-dump version doesn't understand your index" end sup-1.1/bin/sup-add0000755000004100000410000000664514246427237014273 0ustar www-datawww-data#!/usr/bin/env ruby $:.unshift File.join(File.dirname(__FILE__), *%w[.. lib]) require 'uri' require 'optimist' require "sup" require 'sup/util/axe' $opts = Optimist::options do version "sup-add (sup #{Redwood::VERSION})" banner <+ where + is one or more source URIs. For mbox files on local disk, use the form: mbox:, or mbox:// For Maildir folders, use the form: maildir:; or maildir:// Options are: EOS opt :archive, "Automatically archive all new messages from these sources." opt :unusual, "Do not automatically poll these sources for new messages." opt :sync_back, "Synchronize status flags back into messages, defaults to true (Maildir sources only).", :default => true opt :labels, "A comma-separated set of labels to apply to all messages from this source", :type => String opt :force_new, "Create a new account for this source, even if one already exists." opt :force_account, "Reuse previously defined account user@hostname.", :type => String end Optimist::die "require one or more sources" if ARGV.empty? ## for sources that require login information, prompt the user for ## that. also provide a list of previously-defined login info to ## choose from, if any. def get_login_info uri, sources uri = URI(uri) accounts = sources.map do |s| next unless s.respond_to?(:username) suri = URI(s.uri) [suri.host, s.username, s.password] end.compact.uniq.sort_by { |h, u, p| h == uri.host ? 0 : 1 } username, password = nil, nil unless accounts.empty? || $opts[:force_new] if $opts[:force_account] host, username, password = accounts.find { |h, u, p| $opts[:force_account] == "#{u}@#{h}" } unless username && password @cli.say "No previous account #{$opts[:force_account].inspect} found." end else @cli.say "Would you like to use the same account as for a previous source for #{uri}?" @cli.choose do |menu| accounts.each do |host, olduser, oldpw| menu.choice("Use the account info for #{olduser}@#{host}") { username, password = olduser, oldpw } end menu.choice("Use a new account") { } menu.prompt = "Account selection? " end end end unless username && password username = @cli.ask("Username for #{uri.host}: "); password = @cli.ask("Password for #{uri.host}: ") { |q| q.echo = false } puts # why? end [username, password] end @cli.wrap_at = :auto Redwood::start index = Redwood::Index.init index.load index.lock_interactively or exit begin Redwood::SourceManager.load_sources ARGV.each do |uri| labels = $opts[:labels] ? $opts[:labels].split(/\s*,\s*/).uniq : [] if !$opts[:force_new] && Redwood::SourceManager.source_for(uri) @cli.say "Already know about #{uri}; skipping." next end source = case uri when /^maildir:/ Redwood::Maildir.new uri, !$opts[:unusual], $opts[:archive], $opts[:sync_back], nil, labels when /^mbox:/ Redwood::MBox.new uri, !$opts[:unusual], $opts[:archive], nil, labels when nil Optimist::die "Sources must be specified with a maildir:// or mbox:// URI" end @cli.say "Adding #{source}..." Redwood::SourceManager.add_source source end ensure index.save index.unlock Redwood::finish end sup-1.1/bin/sup0000755000004100000410000003037314246427237013540 0ustar www-datawww-data#!/usr/bin/env ruby # encoding: utf-8 $:.unshift File.join(File.dirname(__FILE__), *%w[.. lib]) require 'ncursesw' require 'sup/util/ncurses' require 'sup/util/locale_fiddler' require 'sup/util/axe' no_gpgme = false begin require 'gpgme' rescue LoadError no_gpgme = true end require 'fileutils' require 'optimist' require "sup" if ENV['SUP_PROFILE'] require 'ruby-prof' RubyProf.start end if no_gpgme info "No 'gpgme' gem detected. Install it for email encryption, decryption and signatures." end $opts = Optimist::options do version "sup v#{Redwood::VERSION}" banner < String opt :compose, "Compose message to this recipient upon startup", :type => String opt :subject, "When composing, use this subject", :type => String, :short => "j" end Optimist::die :subject, "requires --compose" if $opts[:subject] && !$opts[:compose] Redwood::HookManager.register "startup", < e warn "cannot dlload setlocale(); ncurses wide character support probably broken." warn "dlload error was #{e.class}: #{e.message}" end end def start_cursing Ncurses.initscr Ncurses.noecho Ncurses.cbreak Ncurses.stdscr.keypad 1 Ncurses.use_default_colors Ncurses.curs_set 0 Ncurses.start_color Ncurses.prepare_form_driver $cursing = true end def stop_cursing return unless $cursing Ncurses.curs_set 1 Ncurses.echo Ncurses.endwin end module_function :start_cursing, :stop_cursing Index.init Index.lock_interactively or exit begin Redwood::start Index.load Redwood::check_syncback_settings Index.start_sync_worker unless $opts[:no_threads] $die = false trap("TERM") { |x| $die = true } trap("WINCH") do |x| ::Thread.new do BufferManager.sigwinch_happened! end end if(s = Redwood::SourceManager.source_for DraftManager.source_name) DraftManager.source = s else debug "no draft source, auto-adding..." Redwood::SourceManager.add_source DraftManager.new_source end if(s = Redwood::SourceManager.source_for SentManager.source_uri) SentManager.source = s else Redwood::SourceManager.add_source SentManager.default_source end HookManager.run "startup" Redwood::Keymap.run_hook global_keymap debug "starting curses" Redwood::Logger.remove_sink $stderr start_cursing bm = BufferManager.init Colormap.new.populate_colormap debug "initializing log buffer" lmode = Redwood::LogMode.new "system log" lmode.on_kill { Logger.clear! } Logger.add_sink lmode Logger.force_message "Welcome to Sup! Log level is set to #{Logger.level}." if Logger::LEVELS.index(Logger.level) > 0 Logger.force_message "For more verbose logging, restart with SUP_LOG_LEVEL=#{Logger::LEVELS[Logger::LEVELS.index(Logger.level)-1]}." end debug "initializing inbox buffer" imode = InboxMode.new ibuf = bm.spawn "Inbox", imode debug "ready for interaction!" bm.draw_screen Redwood::SourceManager.usual_sources.each do |s| next unless s.respond_to? :connect reporting_thread("call #connect on #{s}") do begin s.connect rescue SourceError => e error "fatal error loading from #{s}: #{e.message}" end end end unless $opts[:no_initial_poll] imode.load_threads :num => ibuf.content_height, :when_done => lambda { |num| reporting_thread("poll after loading inbox") { sleep 1; PollManager.poll } unless $opts[:no_threads] || $opts[:no_initial_poll] } if $opts[:compose] to = Person.from_address_list $opts[:compose] mode = ComposeMode.new :to => to, :subj => $opts[:subject] BufferManager.spawn "New Message", mode mode.default_edit_message end unless $opts[:no_threads] PollManager.start IdleManager.start Index.start_lock_update_thread end if $opts[:search] SearchResultsMode.spawn_from_query $opts[:search] end until Redwood::exceptions.nonempty? || $die c = begin Ncurses::CharCode.get false rescue Interrupt raise if BufferManager.ask_yes_or_no "Die ungracefully now?" BufferManager.draw_screen Ncurses::CharCode.empty end if c.empty? if BufferManager.sigwinch_happened? debug "redrawing screen on sigwinch" BufferManager.completely_redraw_screen end next end IdleManager.ping if c.is_keycode? 410 ## this is ncurses's way of telling us it's detected a refresh. ## since we have our own sigwinch handler, we don't do anything. next end bm.erase_flash action = begin if bm.handle_input c :nothing else bm.resolve_input_with_keymap c, global_keymap end rescue InputSequenceAborted :nothing end case action when :quit_now break if bm.kill_all_buffers_safely when :quit_ask if bm.ask_yes_or_no "Really quit?" break if bm.kill_all_buffers_safely end when :help curmode = bm.focus_buf.mode bm.spawn_unless_exists("") { HelpMode.new curmode, global_keymap } when :roll_buffers bm.roll_buffers when :roll_buffers_backwards bm.roll_buffers_backwards when :kill_buffer bm.kill_buffer_safely bm.focus_buf when :list_buffers bm.spawn_unless_exists("buffer list", :system => true) { BufferListMode.new } when :list_contacts b, new = bm.spawn_unless_exists("Contact List") { ContactListMode.new } b.mode.load_in_background if new when :search completions = LabelManager.all_labels.map { |l| "label:#{LabelManager.string_for l}" } completions = completions.each { |l| l.fix_encoding! } completions += Index::COMPL_PREFIXES query = BufferManager.ask_many_with_completions :search, "Search all messages (enter for saved searches): ", completions unless query.nil? if query.empty? bm.spawn_unless_exists("Saved searches") { SearchListMode.new } else SearchResultsMode.spawn_from_query query end end when :search_unread SearchResultsMode.spawn_from_query "is:unread" when :list_labels labels = LabelManager.all_labels.map { |l| LabelManager.string_for l } labels = labels.each { |l| l.fix_encoding! } user_label = bm.ask_with_completions :label, "Show threads with label (enter for listing): ", labels unless user_label.nil? if user_label.empty? bm.spawn_unless_exists("Label list") { LabelListMode.new } if user_label && user_label.empty? else LabelSearchResultsMode.spawn_nicely user_label end end when :compose ComposeMode.spawn_nicely when :poll reporting_thread("user-invoked poll") { PollManager.poll } when :poll_unusual if BufferManager.ask_yes_or_no "Really poll unusual sources?" reporting_thread("user-invoked unusual poll") { PollManager.poll_unusual } end when :recall_draft case Index.num_results_for :label => :draft when 0 bm.flash "No draft messages." when 1 m = nil Index.each_id_by_date(:label => :draft) { |mid, builder| m = builder.call } r = ResumeMode.new(m) BufferManager.spawn "Edit message", r r.default_edit_message else b, new = BufferManager.spawn_unless_exists("All drafts") { LabelSearchResultsMode.new [:draft] } b.mode.load_threads :num => b.content_height if new end when :show_inbox BufferManager.raise_to_front ibuf when :clear_hooks HookManager.clear when :show_console b, new = bm.spawn_unless_exists("Console", :system => true) { ConsoleMode.new } b.mode.run when :reload_colors Colormap.reset Colormap.populate_colormap bm.completely_redraw_screen bm.flash "reloaded colors" when :run_keybindings_hook HookManager.clear_one 'keybindings' Keymap.run_hook global_keymap bm.flash "keybindings hook run" when :nothing, InputSequenceAborted when :redraw bm.completely_redraw_screen else bm.flash "Unknown keypress '#{c.to_character}' for #{bm.focus_buf.mode.name}." end bm.draw_screen end bm.kill_all_buffers if $die rescue Exception => e Redwood::record_exception e, "main" ensure unless $opts[:no_threads] PollManager.stop if PollManager.instantiated? IdleManager.stop if IdleManager.instantiated? Index.stop_lock_update_thread end HookManager.run "shutdown" if HookManager.instantiated? Index.stop_sync_worker Redwood::finish stop_cursing Redwood::Logger.remove_all_sinks! Redwood::Logger.add_sink $stderr, false debug "stopped cursing" if $die info "I've been ordered to commit seppuku. I obey!" end if Redwood::exceptions.empty? debug "no fatal errors. good job, william." Index.save else error "oh crap, an exception" end Index.unlock if (fn = ENV['SUP_PROFILE']) result = RubyProf.stop File.open(fn, 'w') { |io| RubyProf::CallTreePrinter.new(result).print(io) } end end unless Redwood::exceptions.empty? File.open(File.join(BASE_DIR, "exception-log.txt"), "w") do |f| Redwood::exceptions.each do |e, name| f.puts "--- #{e.class.name} from thread: #{name}" f.puts e.message, e.backtrace end end $stderr.puts < If given, list all hooks and descriptions matching the given pattern. Needs the \[en]list-hooks option (default: ) .TP -n, --no-threads Turn off threading. Helps with debugging. (Necessarily disables background polling for new messages.) .TP -o, --no-initial-poll Don\[cq]t poll for new messages when starting. .TP -s \f[I]QUERY\f[R], --search \f[I]QUERY\f[R] Search for this query upon startup .TP -c \f[I]STRING\f[R], --compose \f[I]STRING\f[R] Compose message to this recipient upon startup .TP -j \f[I]STRING\f[R], --subject \f[I]STRING\f[R] When composing, use this subject .TP -v, --version Print version and exit .TP -h, --help Show brief help message .SH ENVIRONMENT .TP SUP_LOG_LEVEL Set log level verbosity. Valid values ordered by decresing verbosity: debug info warn error. Default log level is info. .TP SUP_BASE\[rs] Specify home directory for configuration files and xapian index, defaults to: $HOME/.sup. .SH FILES .TP $HOME/.sup/config.yaml Configuration file for Sup .TP $HOME/.sup/sources.yaml Configuration file for Sup mail sources .TP $HOME/.sup/colors.yaml Color theme for Sup .SH SEE ALSO .PP mail(1), sup-add(1), sup-config(1), sup-dump(1), sup-import-dump(1), sup-recover-sources(1), sup-sync(1), sup-sync-back-maildir(1), sup-tweak-labels(1) .SH REPORTING BUGS .PP You are welcome to submit bug reports to the Sup issue tracker, located at .PP .SH CONTACT INFORMATION .TP The Sup web page: .TP Code repository: .TP Sup Wiki: .TP Sup IRC channel: #sup \[at] freenode.net .TP Mailing list: supmua\[at]googlegroups.com .RS .PP supmua+subscribe\[at]googlegroups.com .PP Archives: .RE .SH COPYRIGHT .PP Copyright \[co] 2006-2009 William Morgan .PP Copyright \[at] 2013-2014 Sup developers .PP Permission is granted to copy and distribute this manual under the terms of the GNU General Public License; either version 2 or (at your option) any later version. .SH AUTHORS Sup was originally written by William Morgan and is now developed and maintained by the Sup developers. sup-1.1/man/sup-recover-sources.10000644000004100000410000000470414246427237017022 0ustar www-datawww-data.\" Automatically generated by Pandoc 2.9.2.1 .\" .TH "SUP-RECOVER-SOURCES" "1" "April 9, 2012" "Sup User Manual" "" .hy .SH NAME .PP sup-recover-sources - rebuild a lost Sup source configuration file .SH SYNOPSIS .PP sup-recover-sources [\f[I]options\f[R]] [\f[I]source uri\&...\f[R]] .SH DESCRIPTION .PP Rebuilds a lost sources.yaml file by reading messages from a list of sources and determining, for each source, the most prevalent `source_id' field of messages from that source in the index. .PP The only non-deterministic component to this is that if the same message appears in multiple sources, those sources may be mis-diagnosed by this program. .PP If the first N messages (--scan-num below) all have the same source_id in the index, the source will be added to sources.yaml. Otherwise, the distribution will be printed, and you will have to add it by hand. .PP The offset pointer into the sources will be set to the end of the source, so you will have to run sup-import --rebuild for each new source after doing this. .SH OPTIONS .TP --unusual Mark sources as `unusual'. Only usual sources will be polled by hand (default: false) .TP --archive Mark sources as `archive'. New messages from these sources will not appear in the inbox (default: false) .TP --scan-num N Number of messages to scan per source (default: 10) .TP -h, --help Show help message .SH FILES .TP $HOME/.sup/sources.yaml Configuration file for Sup mail sources .SH SEE ALSO .PP sup(1), sup-config(1), sup-add(1), sup-import(1) .SH REPORTING BUGS .PP You are welcome to submit bug reports to the Sup issue tracker, located at .PP .SH CONTACT INFORMATION .TP The Sup web page: .TP Code repository: .TP Sup Wiki: .TP Sup IRC channel: #sup \[at] freenode.net .TP Mailing list: supmua\[at]googlegroups.com .RS .PP supmua+subscribe\[at]googlegroups.com .PP Archives: .RE .SH COPYRIGHT .PP Copyright \[co] 2006-2009 William Morgan .PP Copyright \[at] 2013-2014 Sup developers .PP Permission is granted to copy and distribute this manual under the terms of the GNU General Public License; either version 2 or (at your option) any later version. .SH AUTHORS Sup was originally written by William Morgan and is now developed and maintained by the Sup developers. sup-1.1/man/sup-import-dump.10000644000004100000410000000417714246427237016155 0ustar www-datawww-data.\" Automatically generated by Pandoc 2.9.2.1 .\" .TH "SUP-IMPORT-DUMP" "1" "April 9, 2012" "Sup User Manual" "" .hy .SH NAME .PP sup-import-dump - import message state dump to Sup index .SH SYNOPSIS .PP sup-import-dump [\f[I]options\f[R]] dumpfile .SH DESCRIPTION .PP Imports message state previously exported by sup-dump into the index. sup-import-dump operates on the index only, so the messages must have already been added using sup-sync. If you need to recreate the index, see sup-sync --restore instead. .PP Messages not mentioned in the dump file will not be modified. .SH OPTIONS .TP -v, --verbose Print message ids as they\[cq]re processed .TP -i, --ignore-missing Silently skip over messages that are not in the index .TP -w, --warn-missing Warn about messages that are not in the index, but continue .TP -a, --abort-missing Abort on encountering messages that are not in the index (default) .TP -t, --atomic Use transaction to apply all changes atomically .TP -n, --dry-run Don\[cq]t actually modify the index. Probably only useful with --verbose .TP --version Show version information .TP -h, --help Show help message .SH SEE ALSO .PP sup(1), sup-sync(1), sup-dump(1) .SH REPORTING BUGS .PP You are welcome to submit bug reports to the Sup issue tracker, located at .PP .SH CONTACT INFORMATION .TP The Sup web page: .TP Code repository: .TP Sup Wiki: .TP Sup IRC channel: #sup \[at] freenode.net .TP Mailing list: supmua\[at]googlegroups.com .RS .PP supmua+subscribe\[at]googlegroups.com .PP Archives: .RE .SH COPYRIGHT .PP Copyright \[co] 2006-2009 William Morgan .PP Copyright \[at] 2013-2014 Sup developers .PP Permission is granted to copy and distribute this manual under the terms of the GNU General Public License; either version 2 or (at your option) any later version. .SH AUTHORS Sup was originally written by William Morgan and is now developed and maintained by the Sup developers. sup-1.1/man/sup-psych-ify-config-files.10000644000004100000410000000343214246427237020147 0ustar www-datawww-data.\" Automatically generated by Pandoc 2.9.2.1 .\" .TH "SUP-SYNC" "1" "September 3, 2014" "Sup User Manual" "" .hy .SH NAME .PP sup-psych-ify-config-files - migrate Sup configuration .SH SYNOPSIS .PP sup-psych-ify-config-files .SH DESCRIPTION .PP \f[B]YAML migration is deprecated by Ruby 2.1 and newer.\f[R] .PP If sup-psych-ify-config-files is executed by Ruby <= 2.0 it migrates the \f[I]$HOME/.sup/sources.yaml\f[R] configuration file by reading it with the SYCK YAML parser and emitting the results with the Psych YAML emitter. .PP Read more on the Sup wiki .PP https://github.com/sup-heliotrope/sup/wiki/Migration-0.13-to-0.14 .SH FILES .TP $HOME/.sup/config.yaml Sup configuration file .TP $HOME/.sup/sources.yaml Configuration file for Sup mail sources .SH SEE ALSO .PP sup(1), sup-add(1), sup-config(1), sup-dump(1) .SH REPORTING BUGS .PP You are welcome to submit bug reports to the Sup issue tracker, located at .PP .SH CONTACT INFORMATION .TP The Sup web page: .TP Code repository: .TP Sup Wiki: .TP Sup IRC channel: #sup \[at] freenode.net .TP Mailing list: supmua\[at]googlegroups.com .RS .PP supmua+subscribe\[at]googlegroups.com .PP Archives: .RE .SH COPYRIGHT .PP Copyright \[co] 2006-2009 William Morgan .PP Copyright \[at] 2013-2014 Sup developers .PP Permission is granted to copy and distribute this manual under the terms of the GNU General Public License; either version 2 or (at your option) any later version. .SH AUTHORS Sup was originally written by William Morgan and is now developed and maintained by the Sup developers. sup-1.1/man/sup-add.10000644000004100000410000000431014246427237014415 0ustar www-datawww-data.\" Automatically generated by Pandoc 2.9.2.1 .\" .TH "SUP-ADD" "1" "April 9, 2012" "Sup User Manual" "" .hy .SH NAME .PP sup-add - add a source to the Sup source list .SH SYNOPSIS .PP sup-add [\f[I]options\f[R]] [\f[I]source uri\&...\f[R]] .SH DESCRIPTION .PP Add one ore more sources to the Sup source list .PP For mbox files on local disk, use the form: .IP .nf \f[C] mbox:, or mbox:// \f[R] .fi .PP For Maildir folders, use the form: .IP .nf \f[C] maildir:; or maildir:// \f[R] .fi .SH OPTIONS .TP -a, --archive Automatically archive all new messages from thesesources. .TP -u, --unusual Do not automatically poll these sources for new messages. .TP -l \f[I]STRING\f[R], --labels \f[I]STRING\f[R] A comma-separated set of labels to apply to all messages from this source .TP -f, --force-new Create a new account for this source, even if one already exists .TP -o \f[I]STRING\f[R], --force-account \f[I]STRING\f[R] Reuse previously defined account user\[at]hostname .TP -v, --version Print version and exit .TP -h, --help Show help message .SH FILES .TP $HOME/.sup/sources.yaml Configuration file for Sup mail sources .SH SEE ALSO .PP sup(1), sup-config(1) .SH REPORTING BUGS .PP You are welcome to submit bug reports to the Sup issue tracker, located at .PP .SH CONTACT INFORMATION .TP The Sup web page: .TP Code repository: .TP Sup Wiki: .TP Sup IRC channel: #sup \[at] freenode.net .TP Mailing list: supmua\[at]googlegroups.com .RS .PP supmua+subscribe\[at]googlegroups.com .PP Archives: .RE .SH COPYRIGHT .PP Copyright \[co] 2006-2009 William Morgan .PP Copyright \[at] 2013-2014 Sup developers .PP Permission is granted to copy and distribute this manual under the terms of the GNU General Public License; either version 2 or (at your option) any later version. .SH AUTHORS Sup was originally written by William Morgan and is now developed and maintained by the Sup developers. sup-1.1/man/sup-tweak-labels.10000644000004100000410000000431014246427237016240 0ustar www-datawww-data.\" Automatically generated by Pandoc 2.9.2.1 .\" .TH "SUP-TWEAK-LABELS" "1" "April 9, 2012" "Sup User Manuel" "" .hy .SH NAME .PP sup-tweak-labels - batch modification of message state already in index .SH SYNOPSIS .PP sup-tweak-labels [\f[I]options\f[R]] source \&... .SH DESCRIPTION .PP Batch modification of message state for messages already in the index. .PP Supported source URI schemes can be seen by running \[lq]sup-add --help\[rq]. .SH OPTIONS .TP -a \f[I]STRING\f[R], --add \f[I]STRING\f[R] One or more labels (comma-separated) to add to every message from the specified sources (default: ) .TP -r \f[I]STRING\f[R], --remove \f[I]STRING\f[R] One or more labels (comma-separated) to remove from every message from the specified sources, if those labels are present (default: ) .TP -q \f[I]QUERY\f[R], --query \f[I]QUERY\f[R] A Sup search query .SH OTHER OPTIONS .TP -v, --verbose Print message ids as they\[cq]re processed .TP -e, --very-verbose Print message names and subjects as they\[cq]re processed .TP --all-sources Scan over all sources .TP -n, --dry-run Don\[cq]t actually modify the index. Probably only useful with --verbose .TP --version Show version information .TP -h, --help Show help message .SH SEE ALSO .PP sup(1), sup-add(1) .SH REPORTING BUGS .PP You are welcome to submit bug reports to the Sup issue tracker, located at .PP .SH CONTACT INFORMATION .TP The Sup web page: .TP Code repository: .TP Sup Wiki: .TP Sup IRC channel: #sup \[at] freenode.net .TP Mailing list: supmua\[at]googlegroups.com .RS .PP supmua+subscribe\[at]googlegroups.com .PP Archives: .RE .SH COPYRIGHT .PP Copyright \[co] 2006-2009 William Morgan .PP Copyright \[at] 2013-2014 Sup developers .PP Permission is granted to copy and distribute this manual under the terms of the GNU General Public License; either version 2 or (at your option) any later version. .SH AUTHORS Sup was originally written by William Morgan and is now developed and maintained by the Sup developers. sup-1.1/man/sup-config.10000644000004100000410000000304714246427237015140 0ustar www-datawww-data.\" Automatically generated by Pandoc 2.9.2.1 .\" .TH "SUP-CONFIG" "1" "April 9, 2012" "Sup User Manual" "" .hy .SH NAME .PP sup-config - interactive configuration tool for Sup .SH SYNOPSIS .PP sup-config [\f[I]options\f[R]] .SH DESCRIPTION .PP Interactive configuration tool for Sup. Won\[cq]t destroy existing configuration. .SH OPTIONS .TP -v, --version Print versian and exit .TP -h, --help Show help message .SH FILES .TP $HOME/.sup/config.yaml Configuration file for Sup .TP $HOME/.sup/sources.yaml Configuration file for Sup mail sources .SH SEE ALSO .PP sup(1), sup-add(1) .SH REPORTING BUGS .PP You are welcome to submit bug reports to the Sup issue tracker, located at .PP .SH CONTACT INFORMATION .TP The Sup web page: .TP Code repository: .TP Sup Wiki: .TP Sup IRC channel: #sup \[at] freenode.net .TP Mailing list: supmua\[at]googlegroups.com .RS .PP supmua+subscribe\[at]googlegroups.com .PP Archives: .RE .SH COPYRIGHT .PP Copyright \[co] 2006-2009 William Morgan .PP Copyright \[at] 2013-2014 Sup developers .PP Permission is granted to copy and distribute this manual under the terms of the GNU General Public License; either version 2 or (at your option) any later version. .SH AUTHORS Sup was originally written by William Morgan and is now developed and maintained by the Sup developers. sup-1.1/man/sup-dump.10000644000004100000410000000343114246427237014635 0ustar www-datawww-data.\" Automatically generated by Pandoc 2.9.2.1 .\" .TH "SUP-DUMP" "1" "April 9, 2012" "Sup User Manual" "" .hy .SH NAME .PP sup-dump - dumps message state from Sup index .SH SYNOPSIS .PP sup-dump [\f[I]options\f[R]] .SH DESCRIPTION .PP Dumps all message state from the Sup index to standard out. You can later use sup-sync --restored --restore to recover the index. .PP This tool is primarily useful in the event that a Sup upgrade breaks index format compatibility. .SH OPTIONS .TP -v, --version Print version and exit .TP -h, --help Show help message .SH EXAMPLES .PP Dump message state and store in file .IP .nf \f[C] sup-dump > filename \f[R] .fi .PP Dump message state and compress output to store in file .IP .nf \f[C] sup-dump | bzip2 > filename.bz2 \f[R] .fi .SH SEE ALSO .PP sup(1), sup-sync(1), sup-import-dump(1) .SH REPORTING BUGS .PP You are welcome to submit bug reports to the Sup issue tracker, located at .PP .SH CONTACT INFORMATION .TP The Sup web page: .TP Code repository: .TP Sup Wiki: .TP Sup IRC channel: #sup \[at] freenode.net .TP Mailing list: supmua\[at]googlegroups.com .RS .PP supmua+subscribe\[at]googlegroups.com .PP Archives: .RE .SH COPYRIGHT .PP Copyright \[co] 2006-2009 William Morgan .PP Copyright \[at] 2013-2014 Sup developers .PP Permission is granted to copy and distribute this manual under the terms of the GNU General Public License; either version 2 or (at your option) any later version. .SH AUTHORS Sup was originally written by William Morgan and is now developed and maintained by the Sup developers. sup-1.1/man/sup-sync-back-maildir.10000644000004100000410000000601214246427237017157 0ustar www-datawww-data.\" Automatically generated by Pandoc 2.9.2.1 .\" .TH "SUP-SYNC-BACK-MAILDIR" "1" "August 25, 2014" "Sup User Manual" "" .hy .SH NAME .PP sup-sync-back-maildir - Export Xapian entries to Maildir sources on disk .SH SYNOPSIS .PP sup-sync-back-maildir [\f[I]options\f[R]] [\f[I]source uri\&...\f[R]] .SH DESCRIPTION .PP This script parses the Xapian entries for a given Maildir source and renames (changes maildir flags) e-mail files on disk according to the labels stored in the index. It will export all the changes you made in Sup to your Maildirs so that they can be propagated to your IMAP server with e.g.\ offlineimap. .PP The script also merges some Maildir flags into Sup such as R (replied) and P (passed, forwarded), for instance suppose you have an e-mail file like this: foo_bar:2,FRS (flags are favorite, replied, seen) and its Xapian entry has labels `starred', the merge operation will add the `replied' label to the Xapian entry. .PP If you choose not to merge (-m) you will lose information (`replied'), and in the previous example the file will be renamed to foo_bar:2,FS. .PP Running this script is \f[I]strongly\f[R] recommended when setting the \[lq]sync_back_to_maildir\[rq] option from false to true in config.yaml or changing the \[lq]sync_back\[rq] flag to true for a source in sources.yaml. .PP If no source is given, the default behavior is to sync back all Maildir sources marked as usual and that have not disabled sync back using the configuration parameter sync_back = false in sources.yaml. .SH OPTIONS .TP -n, --no-confirm Don\[cq]t ask for confirmation before synchronizing .TP -m, --no-merge Don\[cq]t merge new supported Maildir flags (R and P) .TP -l, --list-sources List your Maildir sources and exit .TP -u, --unusual-sources-too Sync unusual sources too if no specific source information is given .TP --version Print version and exit .TP -h, --help Show brief help message .SH FILES .TP $HOME/.sup/sources.yaml Configuration file for Sup mail sources .SH SEE ALSO .PP sup(1), sup-add(1), sup-config(1), sup-dump(1), sup-sync(1), sup-tweak-labels(1) .SH REPORTING BUGS .PP You are welcome to submit bug reports to the Sup issue tracker, located at .PP .SH CONTACT INFORMATION .TP The Sup web page: .TP Code repository: .TP Sup Wiki: .TP Sup IRC channel: #sup \[at] freenode.net .TP Mailing list: supmua\[at]googlegroups.com .RS .PP supmua+subscribe\[at]googlegroups.com .PP Archives: .RE .SH COPYRIGHT .PP Copyright \[co] 2006-2009 William Morgan .PP Copyright \[at] 2013-2014 Sup developers .PP Permission is granted to copy and distribute this manual under the terms of the GNU General Public License; either version 2 or (at your option) any later version. .SH AUTHORS Sup was originally written by William Morgan and is now developed and maintained by the Sup developers. sup-1.1/man/sup-sync.10000644000004100000410000000632414246427237014650 0ustar www-datawww-data.\" Automatically generated by Pandoc 2.9.2.1 .\" .TH "SUP-SYNC" "1" "April 9, 2012" "Sup User Manual" "" .hy .SH NAME .PP sup-sync - sychronize the Sup index with message sources .SH SYNOPSIS .PP sup-sync [\f[I]options\f[R]] source \&... .SH DESCRIPTION .PP Synchronizes the Sup index with one or more message sources by adding messages, deleting messages, or changing message state in the index as appropriate. .PP \[lq]Message state\[rq] means read/unread, archived/inbox, starred/unstarred, and all user-defined labels on each message. .PP \[lq]Default source state\[rq] refers to any state that a source itself has keeps about a message. Sup-sync uses this information when adding a new message to the index. The source state is typically limited to read/unread, archived/inbox status and a single label based on the source name. Messages using the default source state are placed in the inbox (i.e.\ not archived) and unstarred. .PP If no sources are given, sync from all usual sources. Supported source URI schemes can be seen by running \[lq]sup-add --help\[rq]. .SH MESSAGE STATE OPTIONS .TP --asis If the message is already in the index, preserve its state. Otherwise, use default source state (default) .TP --restore dumpfile Restore message state from a dump file created with sup-dump. If a message is not in this dumpfile, act as --asis .TP --discard Discard any message state in the index and use the default source state. \f[B]Dangerous!\f[R] .TP -x, - When using the default source state, mark messages as archived. .TP -r, --read When using the default source state, mark messages as read. .TP --extra-labels \f[I]STRING\f[R] When using the default source state, also apply these user-defined labels (a comma-separated list) (default) .SH OTHER OPTIONS .TP -v, --verbose Print message ids as they\[cq]re processed. .TP -o, --optimize As the final operation, optimize the index. .TP --all-sources Scan over all sources. .TP -n, --dry-run Don\[cq]t actually modify the index. Probably only useful with --verbose. .TP --version Show version information .TP -h, --help Show help message .SH FILES .TP $HOME/.sup/sources.yaml Configuration file for Sup mail sources .SH SEE ALSO .PP sup(1), sup-add(1), sup-config(1), sup-dump(1), sup-sync-back-maildir(1), sup-tweak-labels(1) .SH REPORTING BUGS .PP You are welcome to submit bug reports to the Sup issue tracker, located at .PP .SH CONTACT INFORMATION .TP The Sup web page: .TP Code repository: .TP Sup Wiki: .TP Sup IRC channel: #sup \[at] freenode.net .TP Mailing list: supmua\[at]googlegroups.com .RS .PP supmua+subscribe\[at]googlegroups.com .PP Archives: .RE .SH COPYRIGHT .PP Copyright \[co] 2006-2009 William Morgan .PP Copyright \[at] 2013-2014 Sup developers .PP Permission is granted to copy and distribute this manual under the terms of the GNU General Public License; either version 2 or (at your option) any later version. .SH AUTHORS Sup was originally written by William Morgan and is now developed and maintained by the Sup developers. sup-1.1/LICENSE0000644000004100000410000003542214246427237013240 0ustar www-datawww-data GNU GENERAL PUBLIC LICENSE Version 2, June 1991 Copyright (C) 1989, 1991 Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This General Public License applies to most of the Free Software Foundation's software and to any other program whose authors commit to using it. (Some other Free Software Foundation software is covered by the GNU Lesser General Public License instead.) You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs; and that you know you can do these things. To protect your rights, we need to make restrictions that forbid anyone to deny you these rights or to ask you to surrender the rights. These restrictions translate to certain responsibilities for you if you distribute copies of the software, or if you modify it. For example, if you distribute copies of such a program, whether gratis or for a fee, you must give the recipients all the rights that you have. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. We protect your rights with two steps: (1) copyright the software, and (2) offer you this license which gives you legal permission to copy, distribute and/or modify the software. Also, for each author's protection and ours, we want to make certain that everyone understands that there is no warranty for this free software. If the software is modified by someone else and passed on, we want its recipients to know that what they have is not the original, so that any problems introduced by others will not reflect on the original authors' reputations. Finally, any free program is threatened constantly by software patents. We wish to avoid the danger that redistributors of a free program will individually obtain patent licenses, in effect making the program proprietary. To prevent this, we have made it clear that any patent must be licensed for everyone's free use or not licensed at all. The precise terms and conditions for copying, distribution and modification follow. GNU GENERAL PUBLIC LICENSE TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 0. This License applies to any program or other work which contains a notice placed by the copyright holder saying it may be distributed under the terms of this General Public License. The "Program", below, refers to any such program or work, and a "work based on the Program" means either the Program or any derivative work under copyright law: that is to say, a work containing the Program or a portion of it, either verbatim or with modifications and/or translated into another language. (Hereinafter, translation is included without limitation in the term "modification".) Each licensee is addressed as "you". Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running the Program is not restricted, and the output from the Program is covered only if its contents constitute a work based on the Program (independent of having been made by running the Program). Whether that is true depends on what the Program does. 1. You may copy and distribute verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and give any other recipients of the Program a copy of this License along with the Program. You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. 2. You may modify your copy or copies of the Program or any portion of it, thus forming a work based on the Program, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: a) You must cause the modified files to carry prominent notices stating that you changed the files and the date of any change. b) You must cause any work that you distribute or publish, that in whole or in part contains or is derived from the Program or any part thereof, to be licensed as a whole at no charge to all third parties under the terms of this License. c) If the modified program normally reads commands interactively when run, you must cause it, when started running for such interactive use in the most ordinary way, to print or display an announcement including an appropriate copyright notice and a notice that there is no warranty (or else, saying that you provide a warranty) and that users may redistribute the program under these conditions, and telling the user how to view a copy of this License. (Exception: if the Program itself is interactive but does not normally print such an announcement, your work based on the Program is not required to print an announcement.) These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Program, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Program, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Program. In addition, mere aggregation of another work not based on the Program with the Program (or with a work based on the Program) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. 3. You may copy and distribute the Program (or a work based on it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you also do one of the following: a) Accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, b) Accompany it with a written offer, valid for at least three years, to give any third party, for a charge no more than your cost of physically performing source distribution, a complete machine-readable copy of the corresponding source code, to be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, c) Accompany it with the information you received as to the offer to distribute corresponding source code. (This alternative is allowed only for noncommercial distribution and only if you received the program in object code or executable form with such an offer, in accord with Subsection b above.) The source code for a work means the preferred form of the work for making modifications to it. For an executable work, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the executable. However, as a special exception, the source code distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. If distribution of executable or object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place counts as distribution of the source code, even though third parties are not compelled to copy the source along with the object code. 4. You may not copy, modify, sublicense, or distribute the Program except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense or distribute the Program is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. 5. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Program or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Program (or any work based on the Program), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Program or works based on it. 6. Each time you redistribute the Program (or any work based on the Program), the recipient automatically receives a license from the original licensor to copy, distribute or modify the Program subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties to this License. 7. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Program at all. For example, if a patent license would not permit royalty-free redistribution of the Program by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Program. If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply and the section as a whole is intended to apply in other circumstances. It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system, which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. 8. If the distribution and/or use of the Program is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Program under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. 9. The Free Software Foundation may publish revised and/or new versions of the General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of this License, you may choose any version ever published by the Free Software Foundation. 10. If you wish to incorporate parts of the Program into other free programs whose distribution conditions are different, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. NO WARRANTY 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. END OF TERMS AND CONDITIONS sup-1.1/History.txt0000644000004100000410000004316414246427237014437 0ustar www-datawww-data== 1.1 / 2022-05-23 * #588, #577: Sup is now compatible with and tested on Ruby 3.0 and Ruby 3.1. * When the Sup gem installs xapian-ruby, it will install to the user gem directory if the system gem directory is not writable. (Iain Parris) * #571: To and From addresses of enclosed messages are now displayed normally, instead of as Redwood::Person objects. (Iain Parris) * #570: Fixed wording when displaying enclosed messages without Date header. (Iain Parris) * #205, #602: UTF-8 header values are now accepted and handled correctly, as per RFC6532. * #585: Text/plain attachments with invalid charset are now displayed as US-ASCII (with high bytes replaced) instead of crashing. * #424: Spaces are now accepted in RFC2047-encoded header words, even though the RFC forbids them. (Dan Callaghan) * Invalid RFC2047-encoded header words are now displayed in their raw form, rather than trying to forcibly transcode them to UTF-8, as per the RFC's recommendation. (Dan Callaghan) * Sup now decodes UTF-7 correctly instead of crashing. == 1.0 / 2020-07-12 No changes. The version number is incremented to 1.0 to reflect the fact that Sup is mature and no further backwards-incompatible changes are planned. == 0.23 / 2020-07-10 * #505: Shell metacharacters in attachment filenames are no longer escaped when saving the attachment to disk using 's'. Fixes attachments being saved with unnecessary \ escapes in their filename. (Felix Van der Jeugt) * When saving attachments, Sup now creates all parent directories of the destination path. (Matthieu Rakotojaona) * The '@' key now reloads all messages in thread view. (Seva Zhidkov) * #517: The 'g' key now correctly opens each selected URL if multiple URLs are under the cursor, instead of opening the last URL multiple times. (Matthieu Rakotojaona) * The 'g' key now works when the cursor is over a URL inside a signature block. (Michael Dwyer) * The 'reply-to' hook is now passed a 'message' variable, the message being replied to, so that the hook can choose the reply mode based on properties of the message like the list address. (Simon Tatham) * The contacts list now shows also email addresses supplied by the 'extra-contract-addresses' hook. (Matthieu Rakotojaona) * #510: The micalg= parameter is now set correctly when sending multipart/signed messages. Fixes issues with other mail clients which fail to handle a missing micalg= parameter on signed messages. (Dan Callaghan) * Attachments with text/plain MIME type are now correctly decoded according to their charset= parameter. (Dan Callaghan) * Strings returned by the 'mime-decode' hook are now kept in their original encoding and displayed as is, instead of being wrongly transcoded. (Dan Callaghan) * Rendering speed of thread list views is improved. (Vickenty Fesunov) * Fixed display width calculation for emoji. Previously, sender names and thread subjects using emoji would be incorrectly truncated, if the terminal emulator displays emoji as double-width. (Dan Callaghan) * List address is parsed from the non-standard Mailing-List header used by Groups.io. (Dan Callaghan) * Fixed misinterpretation of quoted text where the quote uses both an "---- Original Message ----" marker and > delimiters, for example from Zimbra users. (Steven Lawrance) * Added a new option 'indent_spaces' in config.yaml, to control the number of spaces for indenting each child message when displaying the thread view. The default remains 2. (Antoni Kaniowski) * Attachment sizes are now displayed using standard unit abbreviations like "MiB". (Sharif Olorin) * Sup now gives a hint if a search query results in an empty search after tokenization (for example, if the user tries to search for only punctuation). (Per Andersson) * The --list-hooks option now takes an additional option --hooks-matching, to filter the listed hooks. (Matthieu Rakotojaona) * Fixed ArgumentError in logging methods on Ruby 2.7. (Dan Callaghan) * Fixed FrozenError in verified_ok? method on Ruby 2.7. (Iain Parris) * Fixed Fixnum deprecation warnings on Ruby 2.4+. (Dan Callaghan) * Several dependency version updates. The optimist gem replaces trollop. The unicode-display_width gem is a new dependency. == 0.22.1 / 2015-06-18 * Fix bug (#429) in gem build / install. == 0.22.0 / 2015-06-16 * Use mime-types 2 * Fix ruby style (Zeger-Jan van de Weg) * Johannes Larsen: fix long-standing bug with draft-id mixups causing drafts to disappear. * Various bugs and minor improvements. == 0.21.0 / 2015-02-12 * Key binding to fetch GPG key from keyserver (Matthieu Rakotojaona) * Replace occurences of File.exists? with File.exist? (Zeger-Jan van de Weg) * You can now unsubscribe from mailinglists using an url, if you have a goto-hook setup (Timon Vonk). * Forward attribution can be customized using the forward-attribution hook (Ruthard Baudach) * Do a few more checks for buffer not nil in the hope to fix a few random crashes * Add bash completion (Per Andersson) * Replace dl/import with Fiddle (Timon Vonk) * Drop support for ruby 1.9.3 * Add tests for contact manager and persons (Zeger-Jan van de Weg) == 0.20.0 / 2014-10-06 * add man-pages (generated from wiki) (Per Andersson)! * HTML messages or messages that are decoded with the mime-decode hook are now indexed if the mime-decode hook is set up (Scott Bonds). * OpenBSD support (Scott Bonds)! * goto-hook for keybinding to open URLs. * support special charaters in source URIs (Scott Bonds). * output message id and locations on all load_from_source failures * fix long-standing getlocal bug * make new test GPG keys (old ones expired), valid for one year, script now available in devel/ for making new ones. == 0.19.0 / 2014-07-05 * new check-attachment hook * configure times to be seen in 24h format * new mailinglist: supmua@googlegroups.com == 0.18.0 / 2014-05-19 * new color option, :with_attachment for defining colors for the attachment character. * sup-tweak-labels works again (out of service since sync_back). * gem building is done through bundler * you can now kill a thread using & from thread_view == 0.17.0 / 2014-04-11 * add continuous scrolling to thread view * add option for always editing in async mode * bugfix: fix completion char * bugfix: thread-view: dont close message when it is the first or last == 0.16.0 / 2014-03-21 * sup-sync-back-mbox removed. * safer mime-view attachment file name handling * show thread labels in thread-view-mode * remove lock file if there is no sup alive * deprecate migration script on ruby > 2.1 == 0.15.4 / 2014-02-06 * Various bugfixes == 0.15.3 / 2014-01-27 * Revert non-functioning hidden_alternates and fix some bugs. == 0.15.2 / 2013-12-20 * Use the form_driver_w routine for inputing multibyte chars when available. * Add hidden_alternates configuration option: hidden aliases for the account. == 0.15.1 / 2013-12-04 * Thread children are sorted last-activity latest (bottom). == 0.15.0 / 2013-11-07 * Maildir Syncback has now been merged into main sup! This is a long-time waiting feature initially developed by Damien Leone, then picked up by Edward Z. Yang who continued development. Additionally several others have been contributing. Eventually, recently, Eric Weikl has picked up this branch, modernized it to current sup, maintained it and gotten it ready for release. Main authors: Damien Leone Edward Z. Yang Eric Weikl Not all of the features initially proposed have been included. This is to maintain compatibility with more operating systems and wait with the more daring features to make sure sup is stable-ish. This is a big change since sup now can modify your mail (!), please back up your mail and your configuration before using the maildir syncback feature. For instructions on how to migrate an existing maildir source or how to set up a new one, refer to the wiki: https://github.com/sup-heliotrope/sup/wiki/Using-sup-with-other-clients It is possible to both disable maildir syncback globally (default: disabled) and per-source (default: enabled). * Sup on Ruby 2.0.0 now works - but beware, this has not been very throughly tested. Patches are welcome. * We are now using our own rmail-sup gem with fixes for Ruby 2.0.0 and various warnings fixed. * sup-sync-back has been renamed to sup-sync-back-mbox to conform with the other sync-back scripts. * You can now save attachments to directories without specifying the full filename (default filename is used). * Various encoding fixes and minor bug fixes == 0.14.1.1 / 2013-10-29 * SBU1: security release * Tempfiles for attachments are persistent through the sup process to ensure that spawned processes have access to them. == 0.13.2.1 / 2013-10-29 * SBU1: security release == 0.14.1 / 2013-08-31 * Various bugfixes. * Predefined 'All mail' search. == 0.14.0 / 2013-08-15 * CJK compatability * Psych over Syck * Ruby 1.8 deprecated * Thread safety * No more Iconv, but using built in Ruby encodings. Better UTF-8 handling. * GPGME 2.0 support == 0.13.2 / 2013-06-26 * FreeBSD 10 comptability * More threadsafe polling == 0.13.1 / 2013-06-21 * Bugfixes == 0.13.0 / 2013-05-15 * Bugfixes * Depend on ncursesw-sup == 0.12.1 / 2011-01-23 * Depend on ncursesw rather than ncurses (Ruby 1.9 compatibility) * Add sup-import-dump == 0.12 / 2011-01-13 * Remove deprecated IMAP, IMAPS, and mbox+ssh sources * Inline GPG support * Robust maildir support * sup-dump compatibility between Sup versions * New hook: sendmail * Better Ruby 1.9/UTF8 support * As always, many bugfixes and tweaks. == 0.11 / 2010-03-07 * Remove deprecated Ferret backend. * Add deprecation notices to IMAP, IMAPS, and mbox+ssh sources. * 256 color support. * Backwards-compatible index format improvements. * Saved searches. * Improved support for custom keybindings. * Idle detection - poll totals accumulate and index flushes on idle. * Several textfield improvments. * New hooks: publish, mentions-attachments, keybindings, index-mode-date-widget, gpg-args, and crypto-settings. * sup-cmd for easy programmatic access to a Sup index. * As always, many bugfixes and tweaks. == 0.10.2 / 2010-01-26 * Update gem dependencies to pull in xapian-full and ncursesw instead of ferret and ncurses. * Fix a minor problem when running with Ruby 1.8.5. * Fix a warning. == 0.10.1 / 2010-01-24 * Fix a missing file in the gem. == 0.10 / 2010-01-22 * Make Xapian backend the default, and add deprecation notice to Ferret indexes. * Now Ruby 1.9 compatible (Xapian backend only). * Changes are now saved automatically to the index. Pressing "$" now just forces a flush of Xapian indexes, which can minimize quit time. * Fix problem with replying to Google Groups messages. * Allow toggling of line wrap. Useful for long URLs. * Multiple attachments can be added at once by specifying a wildcard. * New command to save all attachments at once. * As always, many bugfixes and tweaks. == 0.9.1 / 2009-12-10 * Make textfield behave more like readline, including C-w * Add ask_for_to config option. You can set all ask_for_* options to false, and composing a message will go immediately to the editor. * RFC 2047 decode attachment file names * default ask_for_to to true * add undo power to thread-view-mode * display labels of polled messages * increase numbers in contact-list-mode * fix --compose option, and add a --subject option * include hook filename in error messages * As always, many bugfixes and tweaks. == 0.9 / 2009-10-01 * Experimental Xapian backend to replace Ferret. Not everything works with it, but it's fast and less likely to barf. See release notes. * New keybinding: "G" for reply-all. * New hook: custom-search, for adding your own query expansions. * Better preemptive thread loading. * Random UI tweaks: display labels before subjects, change thread-view-mode's 'n' and 'p' commands slightly * Better killing of other Sup processes. * Die gracefully upon SIGKILL. * Finally figure out the curses+ruby magic to make SIGWINCH (i.e. xterm resizing) work correctly. * Add a console mode (press ~) for interactively playing with the index. * Finally figure out the curses magic to stop the weird keyboard behavior after leaving the editor. * Improved logging. Logging now supports SUP_LOG_LEVEL environment variable. Set this to "debug" for verbiage. * As always, many bugfixes and tweaks. == 0.8.1 / 2009-06-15 * make multibyte display "work" for non-utf8 locales * fix reply-mode always selecting "Customized" * reduce email quote parsing worst-case behavior == 0.8 / 2009-06-05 * Undo support on many operations. Yay! * Mbox splitting fixes. No more "From "-line problems. * Mail parsing speedups. * Many utf8 and widechar fixes. Display of crazy characters should be pretty close. * Outgoing email with non-ASCII headers is now properly encoded. * Email addresses are no longer permanently attached to names. This was causing problems with automated email systems that used different names with the same address. * Curses background now retains the terminal default color. This also makes Sup work better on transparent terminals. * Improve dynamic loading of setlocale for Cygwin and BSD systems. * Labels can now be removed from multiple tagged threads. * Applying operations to tagged threads is now invoked with '='. * Buffer list is betterified and is now invoked with ';'. * Zsh autocompletion support. * As always, many bugfixes and tweaks. == 0.7 / 2009-03-16 * Ferret index corruption issues fixed (hopefully!) * Text entry now scrolls to the right on overflow, i.e. is actually usable * Ctrl-C now asks user if Sup should die ungracefully * Add a limit: search operator to limit the number of results * Added a --query option to sup-tweak-labels * Added a new hook: shutdown * Automatically add self as recipient on crypted sent messages * Read in X-Foo headers * Added global keybinding 'U' shows only unread messages * As always, many bugfixes and tweaks == 0.6 / 2008-08-04 * new hooks: mark-as-spam, reply-to, reply-from * configurable colors. finally! * many bugfixes * more vi keys added, and 'q' now asks before quitting * attachment markers (little @ signs!) in thread-index-mode * maildir speedups * attachment name searchability * archive-and-mark-read command in inbox-mode == 0.5 / 2008-04-22 * new hooks: extra-contact-addresses, startup * '!!' now loads all threads in current search * general state saving speedup * threads with unsent draft messages are now shown in red * --compose spawns a compose-message buffer on startup * Many bugfixes and UI improvements == 0.4 / 2008-01-23 * GPG support for signing and encrypting outgoing mail * New hooks: mime attachment, attribution line * Improved local charset detection using gettext library * Better quoted region detection * Many bugfixes and UI improvements == 0.3 / 2007-10-29 * In-buffer search (finally!) * Subscribe to/unsubscribe from mailing list commands. * IMAP speedups. * More hooks: set status bar, set terminal title bar, modify message headers and bodies before editing, etc. * Optionally use chronic gem to allow for natural-language dates in searches. * Many, many bugfixes and minor improvements. * Tomorrow is Sup's first birthday! == 0.2 / 2007-10-29 * Complete hook system for user-inserted code. * GPG signature verification and decryption. * Automatically scold users who top-post. * Automatically warn when sending a message with words like "attachment" in the body if there aren't actually any attachments to the message. * Millions of bugfixes. == 0.1 / 2007-07-17 * MIME attachment creation. * i18n support: character set conversion and rfc2047 header decoding. * Better MIME handling. * Multiple account support. * Locking and concurrent Sup process detection and killation. * Thread autoloading when you scroll down. * Batch deletion of messages marked deleted or spam from message sources via sup-sync-back tool (mbox only). * Millions of bugfixes. == 0.0.8 / 2007-04-01 * Maildir support! * New command: sup-config. Interactively walks you through everything you need to get up and running. * Now proactive about notifying users of de-synced sources. * Renamed sup-import => sup-sync with a brand new, less illogical interface. * Added a sup-dump, to enable backing up and rebuilding indices from scratch (e.g. when Ferret upgrades break index formats). * More bugfixes. Will they ever end? == 0.0.7 / 2007-02-12 * Split sup-import into two bits: sup-import and sup-add. * Command-line arguments now handled by trollop. * Better error handling for IMAP and svn+ssh. * Messages can now be moved between sources while preserving all message state. * New commands in thread-view-mode: - 'a' to add an email to the addressbook - 'S' to search for all email to/from an email address - 'A' to kill buffer and archive thread in one swell foop * Removed hoe dependency. == 0.0.6 / 2007-01-06 * Very minor fix to support more types of IMAP authentication. == 0.0.5 / 2007-01-05 * More bugfixes, primarily for IMAP. * doc/UserGuide.txt == 0.0.4 / 2007-01-03 * Bugfixes, primarily for threaded networking. == 0.0.3 / 2007-01-02 * Major speed increase for index views (inbox, search results), which are now loaded completely from the IR index. The only time the original sources need to be touched is when viewing a thread. This is important for slow sources like IMAP and mbox+ssh. * Remote mbox support with mbox+ssh URIs. * IMAP now actually works. * sup-import uses HighLine and is generally much improved. * Multitudinous minor bug fixes and improvements. == 0.0.2 / 2006-12-10 * IMAP support * Better handling of broken sources. (Everything won't just die.) * You will need to rebuild both your index, and sources.yaml. Sorry! == 0.0.1 / 2006-11-28 * Initial release. Unix-centrism, support for mbox only, no i18n. Untested on anything other than 1.8.5. Other than that, works great! sup-1.1/contrib/0000755000004100000410000000000014246427237013665 5ustar www-datawww-datasup-1.1/contrib/colorpicker.rb0000644000004100000410000000321114246427237016523 0ustar www-datawww-datarequire 'ncursesw' Ncurses.initscr Ncurses.noecho Ncurses.cbreak Ncurses.start_color Ncurses.curs_set 0 Ncurses.move 0, 0 Ncurses.clear Ncurses.refresh cc = Ncurses.COLORS Ncurses::keypad(Ncurses::stdscr, 1) Ncurses::mousemask(Ncurses::ALL_MOUSE_EVENTS | Ncurses::REPORT_MOUSE_POSITION, []) fail "color count is #{cc}, expected 256" unless cc == 256 1.upto(255) do |c| Ncurses.init_pair(c, 0, c) end def cell y, x, c @map[[y,x]] = c Ncurses.attron(Ncurses.COLOR_PAIR(c)) Ncurses.mvaddstr(y, x, " ") Ncurses.attroff(Ncurses.COLOR_PAIR(c)) end def handle_click y, x c = @map[[y,x]] or return name = case c when 0...16 c.to_s when 16...232 'c' + (c-16).to_s(6).rjust(3,'0') when 232...256 'g' + (c-232).to_s end Ncurses.mvaddstr 11, 0, "#{name} " Ncurses.attron(Ncurses.COLOR_PAIR(c)) 10.times do |i| 20.times do |j| y = 13 + i x = j Ncurses.mvaddstr(y, x, " ") end end Ncurses.attroff(Ncurses.COLOR_PAIR(c)) end @map = {} @fg = @bg = 0 begin 16.times do |i| cell 0, i, i end 6.times do |i| 6.times do |j| 6.times do |k| c = 16 + 6*6*i + 6*j + k y = 2 + j x = 7*i + k cell y, x, c end end end 16.times do |i| c = 16 + 6*6*6 + i cell 9, i, c end handle_click 0, 0 Ncurses.refresh while (c = Ncurses.getch) case c when 113 #q break when Ncurses::KEY_MOUSE mev = Ncurses::MEVENT.new Ncurses.getmouse(mev) case(mev.bstate) when Ncurses::BUTTON1_CLICKED handle_click mev.y, mev.x end end Ncurses.refresh end ensure Ncurses.endwin end sup-1.1/contrib/completion/0000755000004100000410000000000014246427237016036 5ustar www-datawww-datasup-1.1/contrib/completion/_sup.bash0000644000004100000410000000636514246427237017655 0ustar www-datawww-data# Sup Bash completion # # * Complete options for all Sup commands. # * Disable completion for next option when current option takes an argument. # * Complete sources, directories, and files, where applicable. _sup_cmds() { local cur prev opts sources COMPREPLY=() cur="${COMP_WORDS[COMP_CWORD]}" prev="${COMP_WORDS[COMP_CWORD-1]}" sources="$(sed -n '/uri:/ {s/.*uri:\s*//p}' $HOME/.sup/sources.yaml)" case "${1##/*}" in sup-add) opts="--archive -a --unusual -u --sync-back --no-sync-back -s --labels -l --force-new -f --force-account -o --version -v --help -h mbox: maildir:" case $prev in --labels|-l|--force-account|-o) COMPREPLY=() return 0 ;; esac ;; sup-config|sup-dump) opts="--version -v --help -h" ;; sup-import-dump) opts="--verbose -v --ignore-missing -i --warn-missing -w --abort-missing -a --atomic -t --dry-run -n --version --help -h" ;; sup) opts="--list-hooks -l --no-threads -n --no-initial-poll -o --search -s --compose -c --subject -j --version -v --help -h" case $prev in --search|-s|--compose|-c|--subject|-j) COMPREPLY=() return 0 ;; esac ;; sup-recover-sources) opts="--unusual --archive --scan-num --help -h $sources" case $prev in --scan-num) COMPREPLY=() return 0 ;; esac ;; sup-sync) opts="--asis --restore --discard --archive -x --read -r --extra-labels --verbose -v --optimize -o --all-sources --dry-run -n --version --help -h ${sources}" case $prev in --restore|--extra-labels) COMPREPLY=() return 0 ;; esac ;; sup-sync-back-maildir) maildir_sources="$(echo $sources | tr ' ' '\n' | grep maildir)" opts="--no-confirm -n --no-merge -m --list-sources -l --unusual-sources-too -u --version -v --help -h $maildir_sources" ;; sup-tweak-labels) opts="--add -a --remove -r --query -q --verbose -v --very-verbose -e --all-sources --dry-run -n --no-sync-back -o --version --help -h $sources" case $prev in --add|-a|--remove|-r|--query|-q) COMPREPLY=() return 0 ;; esac ;; esac COMPREPLY=( $(compgen -W "$opts" -- ${cur}) ) return 0 } complete -F _sup_cmds sup \ sup-add \ sup-config \ sup-dump \ sup-recover-sources \ sup-sync \ sup-sync-back-maildir \ sup-tweak-labels complete -F _sup_cmds -o filenames -o plusdirs sup-import-dump sup-1.1/contrib/completion/_sup.zsh0000644000004100000410000001207514246427237017537 0ustar www-datawww-data#compdef sup sup-add sup-config sup-dump sup-sync sup-tweak-labels sup-recover-sources # vim: set et sw=2 sts=2 ts=2 ft=zsh : # TODO: sources completion: maildir://some/dir, mbox://some/file, ... # for sup-add, sup-sync, sup-tweak-labels (( ${+functions[_sup_cmd]} )) || _sup_cmd() { _arguments -s : \ "(--list-hooks -l)"{--list-hooks,-l}"[list all hooks and descriptions, and quit]" \ "(--no-threads -n)"{--no-threads,-n}"[turn off threading]" \ "(--no-initial-poll -o)"{--no-initial-poll,-o}"[Don't poll for new messages when starting]" \ "(--search -s)"{--search,-s}"[search for this query upon startup]:Query: " \ "(--compose -c)"{--compose,-c}"[compose message to this recipient upon startup]:Email: " \ "--version[show version information]" \ "(--help -h)"{--help,-h}"[show help]" } (( ${+functions[_sup_add_cmd]} )) || _sup_add_cmd() { _arguments -s : \ "(--archive -a)"{--archive,-a}"[automatically archive all new messages from this source]" \ "(--unusual -u)"{--unusual,-u}"[do not automatically poll for new messages from this source]" \ "(--labels -l)"{--labels,-l}"[set of labels to apply to all messages from this source]:Labels: " \ "(--force-new -f)"{--force-new,-f}"[create a new account for this source, even if one already exists]" \ "--version[show version information]" \ "(--help -h)"{--help,-h}"[show help]" } (( ${+functions[_sup_config_cmd]} )) || _sup_config_cmd() { _arguments -s : \ "--version[show version information]" \ "(--help -h)"{--help,-h}"[show help]" } (( ${+functions[_sup_dump_cmd]} )) || _sup_dump_cmd() { _arguments -s : \ "--version[show version information]" \ "(--help -h)"{--help,-h}"[show help]" } (( ${+functions[_sup_recover_sources_cmd]} )) || _sup_recover_sources_cmd() { _arguments -s : \ "--archive[automatically archive all new messages from this source]" \ "--scan-num[number of messages to scan per source]:" \ "--unusual[do not automatically poll for new messages from this source]" \ "(--help -h)"{--help,-h}"[show help]" } (( ${+functions[_sup_sync_cmd]} )) || _sup_sync_cmd() { # XXX Add only when --restore is given: (--restored -r) # Add only when --changed or--all are given: (--start-at -s) _arguments -s : \ "--new[operate on new messages only]" \ "(--changed -c)"{--changed,-c}"[scan over the entire source for messages that have been deleted, altered, or moved from another source]" \ "(--restored -r)"{--restored,-r}"[operate only on those messages included in a dump file as specified by --restore which have changed state]" \ "(--all -a)"{--all,-a}"[operate on all messages in the source, regardless of newness or changedness]" \ "(--start-at -s)"{--start-at,-s}"[start at a particular offset]:Offset: " \ "--asis[if the message is already in the index, preserve its state, otherwise, use default source state]" \ "--restore[restore message state from a dump file created with sup-dump]:File:_file" \ "--discard[discard any message state in the index and use the default source state]" \ "(--archive -x)"{--archive,-x}"[mark messages as archived when using the default source state]" \ "(--read -e)"{--read,-e}"[mark messages as read when using the default source state]" \ "--extra-labels[apply these labels when using the default source state]:Labels: " \ "(--verbose -v)"{--verbose,-v}"[print message ids as they're processed]" \ "(--optimize -o)"{--optimize,-o}"[as the final operation, optimize the index]" \ "--all-sources[scan over all sources]" \ "(--dry-run -n)"{--dry-run,-n}"[don't actually modify the index]" \ "--version[show version information]" \ "(--help -h)"{--help,-h}"[show help]" } (( ${+functions[_sup_sync_back_cmd]} )) || _sup_sync_back_cmd() { _arguments -s : \ "(--drop-deleted -d)"{--drop-deleted,-d}"[drop deleted messages]" \ "--move-deleted[move deleted messages to a local mbox file]:File:_file" \ "(--drop-spam -s)"{--drop-spam,-s}"[drop spam messages]" \ "--move-spam[move spam messages to a local mbox file]:File:_file" \ "--with-dotlockfile[specific dotlockfile location (mbox files only)]:File:_file" \ "--dont-use-dotlockfile[don't use dotlockfile to lock mbox files]" \ "(--verbose -v)"{--verbose,-v}"[print message ids as they're processed]" \ "(--dry-run -n)"{--dry-run,-n}"[don't actually modify the index]" \ "--version[show version information]" \ "(--help -h)"{--help,-h}"[show help]" } (( ${+functions[_sup_tweak_labels_cmd]} )) || _sup_tweak_labels_cmd() { _arguments -s : \ "(--add -a)"{--add,-a}"[which labels to add to every message from the specified sources]:Labels: " \ "(--remove -r)"{--remove,-r}"[which labels to remove from every message from the specified sources]:Labels: " \ "--all-sources[scan over all sources]" \ "(--verbose -v)"{--verbose,-v}"[print message ids as they're processed]" \ "(--dry-run -n)"{--dry-run,-n}"[don't actually modify the index]" \ "--version[show version information]" \ "(--help -h)"{--help,-h}"[show help]" } _call_function ret _${words[1]//-/_}_cmd return ret sup-1.1/Rakefile0000644000004100000410000000450714246427237013700 0ustar www-datawww-datarequire 'rake/testtask' require "bundler/gem_tasks" # Manifest.txt file in same folder as this Rakefile manifest_filename = "#{File.dirname(__FILE__)}/Manifest.txt" git_ls_files_command = "git ls-files | LC_ALL=C sort" Rake::TestTask.new(:test) do |test| test.libs << 'test' test.test_files = FileList.new('test/**/test_*.rb') test.verbose = true end task :default => :test task :build => [:man] task :ci => [:test, :rubocop_packaging, :check_manifest, :build] def test_pandoc return system("pandoc -v > /dev/null 2>&1") end task :man do puts "building manpages from wiki.." unless test_pandoc puts "no pandoc installed, needed for manpage generation." return end # test if wiki is cloned unless Dir.exist? 'doc/wiki/man' puts "wiki git repository is not cloned in doc/wiki, try: git submodule update --init." return end unless Dir.exist? 'man' Dir.mkdir 'man' end Dir.glob("doc/wiki/man/*.md").each do |md| m = /^.*\/(?[^\/]*)\.md$/.match(md)[:manpage] puts "generating manpage for: #{m}.." r = system "pandoc -s -f markdown -t man #{md} -o man/#{m}" unless r puts "failed to generate manpage: #{m}." return end end end task :clean do ['man', 'pkg'].each do |d| puts "cleaning #{d}.." FileUtils.rm_r d if Dir.exist? d end end task :manifest do manifest = `#{git_ls_files_command}` if $?.success? then puts "Writing `git ls-files` output to #{manifest_filename}" File.write(manifest_filename, manifest, mode: 'w') else abort "Failed to generate Manifest.txt (with `git ls-files`)" end end task :check_manifest do manifest = `#{git_ls_files_command}` manifest_file_contents = File.read(manifest_filename) if manifest == manifest_file_contents puts "Manifest.txt OK" else puts "Manifest from `git ls-files`:\n#{manifest}" STDERR.puts "Manifest.txt outdated. Please commit an updated Manifest.txt" STDERR.puts "To generate Manifest.txt, run: rake manifest" abort "Manifest.txt does not match `git ls-files`" end end task :rubocop_packaging do if /^2\.[012]\./ =~ RUBY_VERSION puts "skipping rubocop-packaging checks on unsupported Ruby #{RUBY_VERSION}" next end if system("rubocop --only Packaging") puts "rubocop-packaging checks OK" else abort "rubocop-packaging checks failed" end end sup-1.1/lib/0000755000004100000410000000000014246427237012773 5ustar www-datawww-datasup-1.1/lib/sup.rb0000644000004100000410000003403114246427237014130 0ustar www-datawww-data# encoding: utf-8 require 'yaml' require 'zlib' require 'thread' require 'fileutils' require 'locale' require 'ncursesw' require 'rmail' require 'uri' begin require 'fastthread' rescue LoadError end class Object ## this is for debugging purposes because i keep calling #id on the ## wrong object and i want it to throw an exception def id raise "wrong id called on #{self.inspect}" end end class Module def yaml_properties *props props = props.map { |p| p.to_s } path = name.gsub(/::/, "/") yaml_tag "tag:#{Redwood::YAML_DOMAIN},#{Redwood::YAML_DATE}/#{path}" define_method :init_with do |coder| initialize(*coder.map.values_at(*props)) end define_method :encode_with do |coder| coder.map = props.inject({}) do |hash, key| hash[key] = instance_variable_get("@#{key}") hash end end end end module Redwood BASE_DIR = ENV["SUP_BASE"] || File.join(ENV["HOME"], ".sup") CONFIG_FN = File.join(BASE_DIR, "config.yaml") COLOR_FN = File.join(BASE_DIR, "colors.yaml") SOURCE_FN = File.join(BASE_DIR, "sources.yaml") LABEL_FN = File.join(BASE_DIR, "labels.txt") CONTACT_FN = File.join(BASE_DIR, "contacts.txt") DRAFT_DIR = File.join(BASE_DIR, "drafts") SENT_FN = File.join(BASE_DIR, "sent.mbox") LOCK_FN = File.join(BASE_DIR, "lock") SUICIDE_FN = File.join(BASE_DIR, "please-kill-yourself") HOOK_DIR = File.join(BASE_DIR, "hooks") SEARCH_FN = File.join(BASE_DIR, "searches.txt") LOG_FN = File.join(BASE_DIR, "log") SYNC_OK_FN = File.join(BASE_DIR, "sync-back-ok") YAML_DOMAIN = "supmua.org" LEGACY_YAML_DOMAIN = "masanjin.net" YAML_DATE = "2006-10-01" MAILDIR_SYNC_CHECK_SKIPPED = 'SKIPPED' URI_ENCODE_CHARS = "!*'();:@&=+$,?#[] " # see https://en.wikipedia.org/wiki/Percent-encoding ## record exceptions thrown in threads nicely @exceptions = [] @exception_mutex = Mutex.new attr_reader :exceptions def record_exception e, name @exception_mutex.synchronize do @exceptions ||= [] @exceptions << [e, name] end end def reporting_thread name if $opts[:no_threads] yield else ::Thread.new do begin yield rescue Exception => e record_exception e, name end end end end module_function :reporting_thread, :record_exception, :exceptions ## one-stop shop for yamliciousness def save_yaml_obj o, fn, safe=false, backup=false o = if o.is_a?(Array) o.map { |x| (x.respond_to?(:before_marshal) && x.before_marshal) || x } elsif o.respond_to? :before_marshal o.before_marshal else o end mode = if File.exist? fn File.stat(fn).mode else 0600 end if backup backup_fn = fn + '.bak' if File.exist?(fn) && File.size(fn) > 0 File.open(backup_fn, "w", mode) do |f| File.open(fn, "r") { |old_f| FileUtils.copy_stream old_f, f } f.fsync end end File.open(fn, "w") do |f| f.puts o.to_yaml f.fsync end elsif safe safe_fn = "#{File.dirname fn}/safe_#{File.basename fn}" File.open(safe_fn, "w", mode) do |f| f.puts o.to_yaml f.fsync end FileUtils.mv safe_fn, fn else File.open(fn, "w", mode) do |f| f.puts o.to_yaml f.fsync end end end def load_yaml_obj fn, compress=false o = if File.exist? fn raw_contents = if compress Zlib::GzipReader.open(fn) { |f| f.read } else File::open(fn) { |f| f.read } end ## fix up malformed tag URIs created by earlier versions of sup raw_contents.gsub!(/!supmua.org,2006-10-01\/(\S*)$/) { |m| "!" } if YAML.respond_to?(:unsafe_load) # Ruby 3.1+ YAML::unsafe_load raw_contents else YAML::load raw_contents end end if o.is_a?(Array) o.each { |x| x.after_unmarshal! if x.respond_to?(:after_unmarshal!) } else o.after_unmarshal! if o.respond_to?(:after_unmarshal!) end o end def managers %w(HookManager SentManager ContactManager LabelManager AccountManager DraftManager UpdateManager PollManager CryptoManager UndoManager SourceManager SearchManager IdleManager).map { |x| Redwood.const_get x.to_sym } end def start bypass_sync_check = false managers.each { |x| fail "#{x} already instantiated" if x.instantiated? } FileUtils.mkdir_p Redwood::BASE_DIR $config = load_config Redwood::CONFIG_FN @log_io = File.open(Redwood::LOG_FN, 'a') Redwood::Logger.add_sink @log_io Redwood::HookManager.init Redwood::HOOK_DIR Redwood::SentManager.init $config[:sent_source] || 'sup://sent' Redwood::ContactManager.init Redwood::CONTACT_FN Redwood::LabelManager.init Redwood::LABEL_FN Redwood::AccountManager.init $config[:accounts] Redwood::DraftManager.init Redwood::DRAFT_DIR Redwood::SearchManager.init Redwood::SEARCH_FN managers.each { |x| x.init unless x.instantiated? } return if bypass_sync_check if $config[:sync_back_to_maildir] if not File.exist? Redwood::SYNC_OK_FN Redwood.warn_syncback < ENV["EDITOR"] || "/usr/bin/vim -f -c 'setlocal spell spelllang=en_us' -c 'set filetype=mail'", :thread_by_subject => false, :edit_signature => false, :ask_for_from => false, :ask_for_to => true, :ask_for_cc => true, :ask_for_bcc => false, :ask_for_subject => true, :account_selector => true, :confirm_no_attachments => true, :confirm_top_posting => true, :jump_to_open_message => true, :discard_snippets_from_encrypted_messages => false, :load_more_threads_when_scrolling => true, :default_attachment_save_dir => "", :sent_source => "sup://sent", :archive_sent => true, :poll_interval => 300, :wrap_width => 0, :slip_rows => 0, :indent_spaces => 2, :col_jump => 2, :stem_language => "english", :sync_back_to_maildir => false, :continuous_scroll => false, :always_edit_async => false, } if File.exist? filename config = Redwood::load_yaml_obj filename abort "#{filename} is not a valid configuration file (it's a #{config.class}, not a hash)" unless config.is_a?(Hash) default_config.merge config else require 'etc' require 'socket' name = Etc.getpwnam(ENV["USER"]).gecos.split(/,/).first.force_encoding($encoding).fix_encoding! rescue nil name ||= ENV["USER"] email = ENV["USER"] + "@" + begin Addrinfo.getaddrinfo(Socket.gethostname, 'smtp').first.getnameinfo.first rescue SocketError Socket.gethostname end config = { :accounts => { :default => { :name => name.dup.fix_encoding!, :email => email.dup.fix_encoding!, :alternates => [], :sendmail => "/usr/sbin/sendmail -oem -ti", :signature => File.join(ENV["HOME"], ".signature"), :gpgkey => "" } }, } config.merge! default_config begin Redwood::save_yaml_obj config, filename, false, true rescue StandardError => e $stderr.puts "warning: #{e.message}" end config end end module_function :save_yaml_obj, :load_yaml_obj, :start, :finish, :report_broken_sources, :load_config, :managers, :check_syncback_settings end require 'sup/version' require "sup/util" require "sup/hook" require "sup/time" ## everything we need to get logging working require "sup/logger/singleton" ## determine encoding and character set $encoding = Locale.current.charset $encoding = "UTF-8" if $encoding == "utf8" $encoding = "UTF-8" if $encoding == "UTF8" if $encoding debug "using character set encoding #{$encoding.inspect}" else warn "can't find character set by using locale, defaulting to utf-8" $encoding = "UTF-8" end # test encoding teststr = "test" teststr.encode('UTF-8') begin teststr.encode($encoding) rescue Encoding::ConverterNotFoundError warn "locale encoding is invalid, defaulting to utf-8" $encoding = "UTF-8" end require "sup/buffer" require "sup/keymap" require "sup/mode" require "sup/modes/scroll_mode" require "sup/modes/text_mode" require "sup/modes/log_mode" require "sup/update" require "sup/message_chunks" require "sup/message" require "sup/source" require "sup/mbox" require "sup/maildir" require "sup/person" require "sup/account" require "sup/thread" require "sup/interactive_lock" require "sup/index" require "sup/textfield" require "sup/colormap" require "sup/label" require "sup/contact" require "sup/tagger" require "sup/draft" require "sup/poll" require "sup/crypto" require "sup/undo" require "sup/horizontal_selector" require "sup/modes/line_cursor_mode" require "sup/modes/help_mode" require "sup/modes/edit_message_mode" require "sup/modes/edit_message_async_mode" require "sup/modes/compose_mode" require "sup/modes/resume_mode" require "sup/modes/forward_mode" require "sup/modes/reply_mode" require "sup/modes/label_list_mode" require "sup/modes/contact_list_mode" require "sup/modes/thread_view_mode" require "sup/modes/thread_index_mode" require "sup/modes/label_search_results_mode" require "sup/modes/search_results_mode" require "sup/modes/person_search_results_mode" require "sup/modes/inbox_mode" require "sup/modes/buffer_list_mode" require "sup/modes/poll_mode" require "sup/modes/file_browser_mode" require "sup/modes/completion_mode" require "sup/modes/console_mode" require "sup/sent" require "sup/search" require "sup/modes/search_list_mode" require "sup/idle" $:.each do |base| d = File.join base, "sup/share/modes/" Redwood::Mode.load_all_modes d if File.directory? d end sup-1.1/lib/sup/0000755000004100000410000000000014246427237013602 5ustar www-datawww-datasup-1.1/lib/sup/keymap.rb0000644000004100000410000000667114246427237015427 0ustar www-datawww-datarequire 'sup/util/ncurses' module Redwood class Keymap HookManager.register "keybindings", <" when :up then "" when :left then "" when :right then "" when :page_down then "" when :page_up then "" when :backspace then "" when :home then "" when :end then "" when :enter, :return then "" when :tab then "tab" when " " then "" else Ncurses::keyname(keysym_to_keycode(k)) end end def add action, help, *keys entry = [action, help, keys] @order << entry keys.each do |k| kc = Keymap.keysym_to_keycode k raise ArgumentError, "key '#{k}' already defined (as #{@map[kc].first})" if @map.include? kc @map[kc] = entry end end def delete k kc = Keymap.keysym_to_keycode(k) return unless @map.member? kc entry = @map.delete kc keys = entry[2] keys.delete k @order.delete entry if keys.empty? end def add! action, help, *keys keys.each { |k| delete k } add action, help, *keys end def add_multi prompt, key kc = Keymap.keysym_to_keycode(key) if @map.member? kc action = @map[kc].first raise "existing action is not a keymap" unless action.is_a?(Keymap) yield action else submap = Keymap.new add submap, prompt, key yield submap end end def action_for kc action, help, _keys = @map[kc.code] [action, help] end def has_key? k; @map[k.code] end def keysyms; @map.values.map { |action, help, keys| keys }.flatten; end def help_lines except_for={}, prefix="" lines = [] # :( @order.each do |action, help, keys| valid_keys = keys.select { |k| !except_for[k] } next if valid_keys.empty? case action when Symbol lines << [valid_keys.map { |k| prefix + Keymap.keysym_to_string(k) }.join(", "), help] when Keymap lines += action.help_lines({}, prefix + Keymap.keysym_to_string(keys.first)) end end.compact lines end def help_text except_for={} lines = help_lines except_for llen = lines.max_of { |a, b| a.length } lines.map { |a, b| sprintf " %#{llen}s : %s", a, b }.join("\n") end def self.run_hook global_keymap modes = Hash[Mode.keymaps.map { |klass,keymap| [Mode.make_name(klass.name),klass] }] locals = { :modes => modes, :global_keymap => global_keymap, } HookManager.run 'keybindings', locals end end end sup-1.1/lib/sup/mbox.rb0000644000004100000410000001147014246427237015077 0ustar www-datawww-datarequire 'uri' require 'set' module Redwood class MBox < Source BREAK_RE = /^From \S+ (.+)$/ include SerializeLabelsNicely yaml_properties :uri, :usual, :archived, :id, :labels attr_reader :labels ## uri_or_fp is horrific. need to refactor. def initialize uri_or_fp, usual=true, archived=false, id=nil, labels=nil @mutex = Mutex.new @labels = Set.new((labels || []) - LabelManager::RESERVED_LABELS) case uri_or_fp when String @expanded_uri = Source.expand_filesystem_uri(uri_or_fp) parts = /^([a-zA-Z0-9]*:(\/\/)?)(.*)/.match @expanded_uri if parts prefix = parts[1] @path = parts[3] uri = URI(prefix + Source.encode_path_for_uri(@path)) else uri = URI(Source.encode_path_for_uri @expanded_uri) @path = uri.path end raise ArgumentError, "not an mbox uri" unless uri.scheme == "mbox" raise ArgumentError, "mbox URI ('#{uri}') cannot have a host: #{uri.host}" if uri.host raise ArgumentError, "mbox URI must have a path component" unless uri.path @f = nil else @f = uri_or_fp @path = uri_or_fp.path @expanded_uri = "mbox://#{Source.encode_path_for_uri @path}" end super uri_or_fp, usual, archived, id end def file_path; @path end def is_source_for? uri; super || (uri == @expanded_uri) end def self.suggest_labels_for path ## heuristic: use the filename as a label, unless the file ## has a path that probably represents an inbox. if File.dirname(path) =~ /\b(var|usr|spool)\b/ [] else [File.basename(path).downcase.intern] end end def ensure_open @f = File.open @path, 'rb' if @f.nil? end private :ensure_open def go_idle @mutex.synchronize do return if @f.nil? or @path.nil? @f.close @f = nil end end def load_header offset header = nil @mutex.synchronize do ensure_open @f.seek offset header = parse_raw_email_header @f end header end def load_message offset @mutex.synchronize do ensure_open @f.seek offset begin ## don't use RMail::Mailbox::MBoxReader because it doesn't properly ignore ## "From" at the start of a message body line. string = "" until @f.eof? || MBox::is_break_line?(l = @f.gets) string << l end RMail::Parser.read string rescue RMail::Parser::Error => e raise FatalSourceError, "error parsing mbox file: #{e.message}" end end end def raw_header offset ret = "" @mutex.synchronize do ensure_open @f.seek offset until @f.eof? || (l = @f.gets) =~ /^\r*$/ ret << l end end ret end def raw_message offset ret = "" each_raw_message_line(offset) { |l| ret << l } ret end def store_message date, from_email, &block need_blank = File.exist?(@path) && !File.zero?(@path) File.open(@path, "ab") do |f| f.puts if need_blank f.puts "From #{from_email} #{date.asctime}" yield f end end ## apparently it's a million times faster to call this directly if ## we're just moving messages around on disk, than reading things ## into memory with raw_message. ## def each_raw_message_line offset @mutex.synchronize do ensure_open @f.seek offset until @f.eof? || MBox::is_break_line?(l = @f.gets) yield l end end end def default_labels [:inbox, :unread] end def poll first_offset = first_new_message offset = first_offset end_offset = File.size @f while offset and offset < end_offset yield :add, :info => offset, :labels => (labels + default_labels), :progress => (offset - first_offset).to_f/end_offset offset = next_offset offset end end def next_offset offset @mutex.synchronize do ensure_open @f.seek offset nil while line = @f.gets and not MBox::is_break_line? line offset = @f.tell offset != File.size(@f) ? offset : nil end end ## TODO optimize this by iterating over allterms list backwards or ## storing source_info negated def last_indexed_message benchmark(:mbox_read_index) { Index.instance.enum_for(:each_source_info, self.id).map(&:to_i).max } end ## offset of first new message or nil def first_new_message next_offset(last_indexed_message || 0) end def self.is_break_line? l l =~ BREAK_RE or return false time = $1 begin ## hack -- make Time.parse fail when trying to substitute values from Time.now Time.parse time, Time.at(0) true rescue NoMethodError, ArgumentError warn "found invalid date in potential mbox split line, not splitting: #{l.inspect}" false end end class Loader < self yaml_properties :uri, :usual, :archived, :id, :labels end end end sup-1.1/lib/sup/tagger.rb0000644000004100000410000000230714246427237015402 0ustar www-datawww-datarequire 'sup/util/ncurses' module Redwood class Tagger def initialize mode, noun="thread", plural_noun=nil @mode = mode @tagged = {} @noun = noun @plural_noun = plural_noun || (@noun + "s") end def tagged? o; @tagged[o]; end def toggle_tag_for o; @tagged[o] = !@tagged[o]; end def tag o; @tagged[o] = true; end def untag o; @tagged[o] = false; end def drop_all_tags; @tagged.clear; end def drop_tag_for o; @tagged.delete o; end def apply_to_tagged action=nil targets = @tagged.select_by_value num_tagged = targets.size if num_tagged == 0 BufferManager.flash "No tagged threads!" return end noun = num_tagged == 1 ? @noun : @plural_noun unless action c = BufferManager.ask_getch "apply to #{num_tagged} tagged #{noun}:" return if c.empty? # user cancelled action = @mode.resolve_input c end if action tagged_sym = "multi_#{action}".intern if @mode.respond_to? tagged_sym @mode.send tagged_sym, targets else BufferManager.flash "That command cannot be applied to multiple threads." end else BufferManager.flash "Unknown command #{c.to_character}." end end end end sup-1.1/lib/sup/buffer.rb0000644000004100000410000005273014246427237015407 0ustar www-datawww-data# encoding: utf-8 require 'etc' require 'thread' require 'ncursesw' require 'sup/util/ncurses' module Redwood class InputSequenceAborted < StandardError; end class Buffer attr_reader :mode, :x, :y, :width, :height, :title, :atime bool_reader :dirty, :system bool_accessor :force_to_top, :hidden def initialize window, mode, width, height, opts={} @w = window @mode = mode @dirty = true @focus = false @title = opts[:title] || "" @force_to_top = opts[:force_to_top] || false @hidden = opts[:hidden] || false @x, @y, @width, @height = 0, 0, width, height @atime = Time.at 0 @system = opts[:system] || false end def content_height; @height - 1; end def content_width; @width; end def resize rows, cols return if cols == @width && rows == @height @width = cols @height = rows @dirty = true mode.resize rows, cols end def redraw status if @dirty draw status else draw_status status end commit end def mark_dirty; @dirty = true; end def commit @dirty = false @w.noutrefresh end def draw status @mode.draw draw_status status commit @atime = Time.now end ## s nil means a blank line! def write y, x, s, opts={} return if x >= @width || y >= @height @w.attrset Colormap.color_for(opts[:color] || :none, opts[:highlight]) s ||= "" maxl = @width - x # maximum display width width # fill up the line with blanks to overwrite old screen contents @w.mvaddstr y, x, " " * maxl unless opts[:no_fill] @w.mvaddstr y, x, s.slice_by_display_length(maxl) end def clear @w.clear end def draw_status status write @height - 1, 0, status, :color => :status_color end def focus @focus = true @dirty = true @mode.focus end def blur @focus = false @dirty = true @mode.blur end end class BufferManager include Redwood::Singleton attr_reader :focus_buf ## we have to define the key used to continue in-buffer search here, because ## it has special semantics that BufferManager deals with---current searches ## are canceled by any keypress except this one. CONTINUE_IN_BUFFER_SEARCH_KEY = "n" HookManager.register "status-bar-text", <" entries. Variables: none Return value: an array of email address strings. EOS def initialize @name_map = {} @buffers = [] @focus_buf = nil @dirty = true @minibuf_stack = [] @minibuf_mutex = Mutex.new @textfields = {} @flash = nil @shelled = @asking = false @in_x = ENV["TERM"] =~ /(xterm|rxvt|screen)/ @sigwinch_happened = false @sigwinch_mutex = Mutex.new end def sigwinch_happened! @sigwinch_mutex.synchronize do return if @sigwinch_happened @sigwinch_happened = true Ncurses.ungetch ?\C-l.ord end end def sigwinch_happened?; @sigwinch_mutex.synchronize { @sigwinch_happened } end def buffers; @name_map.to_a; end def shelled?; @shelled; end def focus_on buf return unless @buffers.member? buf return if buf == @focus_buf @focus_buf.blur if @focus_buf @focus_buf = buf @focus_buf.focus end def raise_to_front buf @buffers.delete(buf) or return if @buffers.length > 0 && @buffers.last.force_to_top? @buffers.insert(-2, buf) else @buffers.push buf end focus_on @buffers.last @dirty = true end ## we reset force_to_top when rolling buffers. this is so that the ## human can actually still move buffers around, while still ## programmatically being able to pop stuff up in the middle of ## drawing a window without worrying about covering it up. ## ## if we ever start calling roll_buffers programmatically, we will ## have to change this. but it's not clear that we will ever actually ## do that. def roll_buffers bufs = rollable_buffers bufs.last.force_to_top = false raise_to_front bufs.first end def roll_buffers_backwards bufs = rollable_buffers return unless bufs.length > 1 bufs.last.force_to_top = false raise_to_front bufs[bufs.length - 2] end def rollable_buffers @buffers.select { |b| !(b.system? || b.hidden?) || @buffers.last == b } end def handle_input c if @focus_buf if @focus_buf.mode.in_search? && c != CONTINUE_IN_BUFFER_SEARCH_KEY @focus_buf.mode.cancel_search! @focus_buf.mark_dirty end @focus_buf.mode.handle_input c end end def exists? n; @name_map.member? n; end def [] n; @name_map[n]; end def []= n, b raise ArgumentError, "duplicate buffer name" if b && @name_map.member?(n) raise ArgumentError, "title must be a string" unless n.is_a? String @name_map[n] = b end def completely_redraw_screen return if @shelled ## this magic makes Ncurses get the new size of the screen Ncurses.endwin Ncurses.stdscr.keypad 1 Ncurses.curs_set 0 Ncurses.refresh @sigwinch_mutex.synchronize { @sigwinch_happened = false } debug "new screen size is #{Ncurses.rows} x #{Ncurses.cols}" status, title = get_status_and_title(@focus_buf) # must be called outside of the ncurses lock Ncurses.sync do @dirty = true Ncurses.clear draw_screen :sync => false, :status => status, :title => title end end def draw_screen opts={} return if @shelled status, title = if opts.member? :status [opts[:status], opts[:title]] else raise "status must be supplied if draw_screen is called within a sync" if opts[:sync] == false get_status_and_title @focus_buf # must be called outside of the ncurses lock end ## http://rtfm.etla.org/xterm/ctlseq.html (see Operating System Controls) print "\033]0;#{title}\07" if title && @in_x Ncurses.mutex.lock unless opts[:sync] == false ## disabling this for the time being, to help with debugging ## (currently we only have one buffer visible at a time). ## TODO: reenable this if we allow multiple buffers false && @buffers.inject(@dirty) do |dirty, buf| buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols #dirty ? buf.draw : buf.redraw buf.draw status dirty end ## quick hack if true buf = @buffers.last buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols @dirty ? buf.draw(status) : buf.redraw(status) end draw_minibuf :sync => false unless opts[:skip_minibuf] @dirty = false Ncurses.doupdate Ncurses.refresh if opts[:refresh] Ncurses.mutex.unlock unless opts[:sync] == false end ## if the named buffer already exists, pops it to the front without ## calling the block. otherwise, gets the mode from the block and ## creates a new buffer. returns two things: the buffer, and a boolean ## indicating whether it's a new buffer or not. def spawn_unless_exists title, opts={} new = if @name_map.member? title raise_to_front @name_map[title] unless opts[:hidden] false else mode = yield spawn title, mode, opts true end [@name_map[title], new] end def spawn title, mode, opts={} raise ArgumentError, "title must be a string" unless title.is_a? String realtitle = title num = 2 while @name_map.member? realtitle realtitle = "#{title} <#{num}>" num += 1 end width = opts[:width] || Ncurses.cols height = opts[:height] || Ncurses.rows - 1 ## since we are currently only doing multiple full-screen modes, ## use stdscr for each window. once we become more sophisticated, ## we may need to use a new Ncurses::WINDOW ## ## w = Ncurses::WINDOW.new(height, width, (opts[:top] || 0), ## (opts[:left] || 0)) w = Ncurses.stdscr b = Buffer.new w, mode, width, height, :title => realtitle, :force_to_top => opts[:force_to_top], :system => opts[:system] mode.buffer = b @name_map[realtitle] = b @buffers.unshift b if opts[:hidden] focus_on b unless @focus_buf else raise_to_front b end b end ## requires the mode to have #done? and #value methods def spawn_modal title, mode, opts={} b = spawn title, mode, opts draw_screen until mode.done? c = Ncurses::CharCode.get next unless c.present? # getch timeout break if c.is_keycode? Ncurses::KEY_CANCEL begin mode.handle_input c rescue InputSequenceAborted # do nothing end draw_screen erase_flash end kill_buffer b mode.value end def kill_all_buffers_safely until @buffers.empty? ## inbox mode always claims it's unkillable. we'll ignore it. return false unless @buffers.last.mode.is_a?(InboxMode) || @buffers.last.mode.killable? kill_buffer @buffers.last end true end def kill_buffer_safely buf return false unless buf.mode.killable? kill_buffer buf true end def kill_all_buffers kill_buffer @buffers.first until @buffers.empty? end def kill_buffer buf raise ArgumentError, "buffer not on stack: #{buf}: #{buf.title.inspect}" unless @buffers.member? buf buf.mode.cleanup @buffers.delete buf @name_map.delete buf.title @focus_buf = nil if @focus_buf == buf if @buffers.empty? ## TODO: something intelligent here ## for now I will simply prohibit killing the inbox buffer. else raise_to_front @buffers.last end end ## ask* functions. these functions display a one-line text field with ## a prompt at the bottom of the screen. answers typed or choosen by ## tab-completion ## ## common arguments are: ## ## domain: token used as key for @textfields, which seems to be a ## dictionary of input field objects ## question: string used as prompt ## completions: array of possible answers, that can be completed by using ## the tab key ## default: default value to return def ask_with_completions domain, question, completions, default=nil ask domain, question, default do |s| s.fix_encoding! completions.select { |x| x =~ /^#{Regexp::escape s}/iu }.map { |x| [x, x] } end end def ask_many_with_completions domain, question, completions, default=nil ask domain, question, default do |partial| prefix, target = case partial when /^\s*$/ ["", ""] when /^(.*\s+)?(.*?)$/ [$1 || "", $2] else raise "william screwed up completion: #{partial.inspect}" end prefix.fix_encoding! target.fix_encoding! completions.select { |x| x =~ /^#{Regexp::escape target}/iu }.map { |x| [prefix + x, x] } end end def ask_many_emails_with_completions domain, question, completions, default=nil ask domain, question, default do |partial| prefix, target = partial.split_on_commas_with_remainder target ||= prefix.pop || "" target.fix_encoding! prefix = prefix.join(", ") + (prefix.empty? ? "" : ", ") prefix.fix_encoding! completions.select { |x| x =~ /^#{Regexp::escape target}/iu }.sort_by { |c| [ContactManager.contact_for(c) ? 0 : 1, c] }.map { |x| [prefix + x, x] } end end def ask_for_filename domain, question, default=nil, allow_directory=false answer = ask domain, question, default do |s| if s =~ /(~([^\s\/]*))/ # twiddle directory expansion full = $1 name = $2.empty? ? Etc.getlogin : $2 dir = Etc.getpwnam(name).dir rescue nil if dir [[s.sub(full, dir), "~#{name}"]] else users.select { |u| u =~ /^#{Regexp::escape name}/u }.map do |u| [s.sub("~#{name}", "~#{u}"), "~#{u}"] end end else # regular filename completion Dir["#{s}*"].sort.map do |fn| suffix = File.directory?(fn) ? "/" : "" [fn + suffix, File.basename(fn) + suffix] end end end if answer answer = if answer.empty? spawn_modal "file browser", FileBrowserMode.new elsif File.directory?(answer) && !allow_directory spawn_modal "file browser", FileBrowserMode.new(answer) else File.expand_path answer end end answer end ## returns an array of labels def ask_for_labels domain, question, default_labels, forbidden_labels=[] default_labels = default_labels - forbidden_labels - LabelManager::RESERVED_LABELS default = default_labels.to_a.join(" ") default += " " unless default.empty? # here I would prefer to give more control and allow all_labels instead of # user_defined_labels only applyable_labels = (LabelManager.user_defined_labels - forbidden_labels).map { |l| LabelManager.string_for l }.sort_by { |s| s.downcase } answer = ask_many_with_completions domain, question, applyable_labels, default return unless answer user_labels = answer.to_set_of_symbols user_labels.each do |l| if forbidden_labels.include?(l) || LabelManager::RESERVED_LABELS.include?(l) BufferManager.flash "'#{l}' is a reserved label!" return end end user_labels end def ask_for_contacts domain, question, default_contacts=[] default = default_contacts.is_a?(String) ? default_contacts : default_contacts.map { |s| s.to_s }.join(", ") default += " " unless default.empty? recent = Index.load_contacts(AccountManager.user_emails, :num => 10).map { |c| [c.full_address, c.email] } contacts = ContactManager.contacts.map { |c| [ContactManager.alias_for(c), c.full_address, c.email] } completions = (recent + contacts).flatten.uniq completions += HookManager.run("extra-contact-addresses") || [] answer = BufferManager.ask_many_emails_with_completions domain, question, completions, default if answer answer.split_on_commas.map { |x| ContactManager.contact_for(x) || Person.from_address(x) } end end def ask_for_account domain, question completions = AccountManager.user_emails answer = BufferManager.ask_many_emails_with_completions domain, question, completions, "" answer = AccountManager.default_account.email if answer == "" AccountManager.account_for Person.from_address(answer).email if answer end ## for simplicitly, we always place the question at the very bottom of the ## screen def ask domain, question, default=nil, &block raise "impossible!" if @asking raise "Question too long" if Ncurses.cols <= question.length @asking = true @textfields[domain] ||= TextField.new tf = @textfields[domain] completion_buf = nil status, title = get_status_and_title @focus_buf Ncurses.sync do tf.activate Ncurses.stdscr, Ncurses.rows - 1, 0, Ncurses.cols, question, default, &block @dirty = true # for some reason that blanks the whole fucking screen draw_screen :sync => false, :status => status, :title => title tf.position_cursor Ncurses.refresh end while true c = Ncurses::CharCode.get next unless c.present? # getch timeout break unless tf.handle_input c # process keystroke if tf.new_completions? kill_buffer completion_buf if completion_buf shorts = tf.completions.map { |full, short| short } prefix_len = shorts.shared_prefix(caseless=true).length mode = CompletionMode.new shorts, :header => "Possible completions for \"#{tf.value}\": ", :prefix_len => prefix_len completion_buf = spawn "", mode, :height => 10 draw_screen :skip_minibuf => true tf.position_cursor elsif tf.roll_completions? completion_buf.mode.roll draw_screen :skip_minibuf => true tf.position_cursor end Ncurses.sync { Ncurses.refresh } end kill_buffer completion_buf if completion_buf @dirty = true @asking = false Ncurses.sync do tf.deactivate draw_screen :sync => false, :status => status, :title => title end tf.value.tap { |x| x } end def ask_getch question, accept=nil raise "impossible!" if @asking accept = accept.split(//).map { |x| x.ord } if accept status, title = get_status_and_title @focus_buf Ncurses.sync do draw_screen :sync => false, :status => status, :title => title Ncurses.mvaddstr Ncurses.rows - 1, 0, question Ncurses.move Ncurses.rows - 1, question.length + 1 Ncurses.curs_set 1 Ncurses.refresh end @asking = true ret = nil done = false until done key = Ncurses::CharCode.get next if key.empty? if key.is_keycode? Ncurses::KEY_CANCEL done = true elsif accept.nil? || accept.empty? || accept.member?(key.code) ret = key done = true end end @asking = false Ncurses.sync do Ncurses.curs_set 0 draw_screen :sync => false, :status => status, :title => title end ret end ## returns true (y), false (n), or nil (ctrl-g / cancel) def ask_yes_or_no question case(r = ask_getch question, "ynYN") when ?y, ?Y true when nil nil else false end end ## turns an input keystroke into an action symbol. returns the action ## if found, nil if not found, and throws InputSequenceAborted if ## the user aborted a multi-key sequence. (Because each of those cases ## should be handled differently.) ## ## this is in BufferManager because multi-key sequences require prompting. def resolve_input_with_keymap c, keymap action, text = keymap.action_for c while action.is_a? Keymap # multi-key commands, prompt key = BufferManager.ask_getch text unless key # user canceled, abort erase_flash raise InputSequenceAborted end action, text = action.action_for(key) if action.has_key?(key) end action end def minibuf_lines @minibuf_mutex.synchronize do [(@flash ? 1 : 0) + (@asking ? 1 : 0) + @minibuf_stack.compact.size, 1].max end end def draw_minibuf opts={} m = nil @minibuf_mutex.synchronize do m = @minibuf_stack.compact m << @flash if @flash m << "" if m.empty? unless @asking # to clear it end Ncurses.mutex.lock unless opts[:sync] == false Ncurses.attrset Colormap.color_for(:text_color) adj = @asking ? 2 : 1 m.each_with_index do |s, i| Ncurses.mvaddstr Ncurses.rows - i - adj, 0, s + (" " * [Ncurses.cols - s.length, 0].max) end Ncurses.refresh if opts[:refresh] Ncurses.mutex.unlock unless opts[:sync] == false end def say s, id=nil new_id = nil @minibuf_mutex.synchronize do new_id = id.nil? id ||= @minibuf_stack.length @minibuf_stack[id] = s end if new_id draw_screen :refresh => true else draw_minibuf :refresh => true end if block_given? begin yield id ensure clear id end end id end def erase_flash; @flash = nil; end def flash s @flash = s draw_screen :refresh => true end ## a little tricky because we can't just delete_at id because ids ## are relative (they're positions into the array). def clear id @minibuf_mutex.synchronize do @minibuf_stack[id] = nil if id == @minibuf_stack.length - 1 id.downto(0) do |i| break if @minibuf_stack[i] @minibuf_stack.delete_at i end end end draw_screen :refresh => true end def shell_out command @shelled = true Ncurses.sync do Ncurses.endwin system command Ncurses.stdscr.keypad 1 Ncurses.refresh Ncurses.curs_set 0 end @shelled = false end private def default_status_bar buf " [#{buf.mode.name}] #{buf.title} #{buf.mode.status}" end def default_terminal_title buf "Sup #{Redwood::VERSION} :: #{buf.title}" end def get_status_and_title buf opts = { :num_inbox => lambda { Index.num_results_for :label => :inbox }, :num_inbox_unread => lambda { Index.num_results_for :labels => [:inbox, :unread] }, :num_total => lambda { Index.size }, :num_spam => lambda { Index.num_results_for :label => :spam }, :title => buf.title, :mode => buf.mode.name, :status => buf.mode.status } statusbar_text = HookManager.run("status-bar-text", opts) || default_status_bar(buf) term_title_text = HookManager.run("terminal-title-text", opts) || default_terminal_title(buf) [statusbar_text, term_title_text] end def users unless @users @users = [] while(u = Etc.getpwent) @users << u.name end end @users end end end sup-1.1/lib/sup/version.rb0000644000004100000410000000032714246427237015616 0ustar www-datawww-datadef git_suffix revision = `GIT_DIR=#{__dir__}/../../.git git rev-parse HEAD 2>/dev/null` if revision.empty? "-git-unknown" else "-git-#{revision[0..7]}" end end module Redwood VERSION = "1.1" end sup-1.1/lib/sup/crypto.rb0000644000004100000410000004317714246427237015463 0ustar www-datawww-databegin require 'gpgme' rescue LoadError end module Redwood class CryptoManager include Redwood::Singleton class Error < StandardError; end OUTGOING_MESSAGE_OPERATIONS = { sign: "Sign", sign_and_encrypt: "Sign and encrypt", encrypt: "Encrypt only" } KEY_PATTERN = /(-----BEGIN PGP PUBLIC KEY BLOCK.*-----END PGP PUBLIC KEY BLOCK)/m KEYSERVER_URL = "http://pool.sks-keyservers.net:11371/pks/lookup" HookManager.register "gpg-options", < true} to encrypting a message, but who knows). Variables: operation: what operation will be done ("sign", "encrypt", "decrypt" or "verify") options: a dictionary of values to be passed to GPGME Return value: a dictionary to be passed to GPGME EOS HookManager.register "sig-output", < GPGME::PROTOCOL_OpenPGP}) rescue TypeError GPGME.check_version(nil) end true rescue GPGME::Error false rescue ArgumentError # gpgme 2.0.0 raises this due to the hash->string conversion false end rescue NameError false end unless @gpgme_present @not_working_reason = ['gpgme gem not present', 'Install the gpgme gem in order to use signed and encrypted emails'] return end # if gpg2 is available, it will start gpg-agent if required if (bin = `which gpg2`.chomp) =~ /\S/ if GPGME.respond_to?('set_engine_info') GPGME.set_engine_info GPGME::PROTOCOL_OpenPGP, bin, nil else GPGME.gpgme_set_engine_info GPGME::PROTOCOL_OpenPGP, bin, nil end else # check if the gpg-options hook uses the passphrase_callback # if it doesn't then check if gpg agent is present gpg_opts = HookManager.run("gpg-options", {:operation => "sign", :options => {}}) || {} if gpg_opts[:passphrase_callback].nil? if ENV['GPG_AGENT_INFO'].nil? @not_working_reason = ["Environment variable 'GPG_AGENT_INFO' not set, is gpg-agent running?", "If gpg-agent is running, try $ export `cat ~/.gpg-agent-info`"] return end gpg_agent_socket_file = ENV['GPG_AGENT_INFO'].split(':')[0] unless File.exist?(gpg_agent_socket_file) @not_working_reason = ["gpg-agent socket file #{gpg_agent_socket_file} does not exist"] return end s = File.stat(gpg_agent_socket_file) unless s.socket? @not_working_reason = ["gpg-agent socket file #{gpg_agent_socket_file} is not a socket"] return end end end end def have_crypto?; @not_working_reason.nil? end def not_working_reason; @not_working_reason end def sign from, to, payload return unknown_status(@not_working_reason) unless @not_working_reason.nil? # We grab this from the GPG::Ctx below after signing, so that we can set # micalg in Content-Type to match the hash algorithm GPG decided to use. hash_algo = nil gpg_opts = {:protocol => GPGME::PROTOCOL_OpenPGP, :armor => true, :textmode => true} gpg_opts.merge!(gen_sign_user_opts(from)) gpg_opts = HookManager.run("gpg-options", {:operation => "sign", :options => gpg_opts}) || gpg_opts begin input = GPGME::Data.new(format_payload(payload)) output = GPGME::Data.new() GPGME::Ctx.new(gpg_opts) do |ctx| if gpg_opts[:signer] signers = GPGME::Key.find(:secret, gpg_opts[:signer], :sign) ctx.add_signer(*signers) end ctx.sign(input, output, GPGME::SIG_MODE_DETACH) hash_algo = GPGME::hash_algo_name(ctx.sign_result.signatures[0].hash_algo) end output.seek(0) sig = output.read rescue GPGME::Error => exc raise Error, gpgme_exc_msg(exc.message) end # if the key (or gpg-agent) is not available GPGME does not complain # but just returns a zero length string. Let's catch that if sig.length == 0 raise Error, gpgme_exc_msg("GPG failed to generate signature: check that gpg-agent is running and your key is available.") end envelope = RMail::Message.new envelope.header["Content-Type"] = "multipart/signed; protocol=application/pgp-signature; micalg=pgp-#{hash_algo.downcase}" envelope.add_part payload signature = RMail::Message.make_attachment sig, "application/pgp-signature", nil, "signature.asc" envelope.add_part signature envelope end def encrypt from, to, payload, sign=false return unknown_status(@not_working_reason) unless @not_working_reason.nil? gpg_opts = {:protocol => GPGME::PROTOCOL_OpenPGP, :armor => true, :textmode => true} if sign gpg_opts.merge!(gen_sign_user_opts(from)) gpg_opts.merge!({:sign => true}) end gpg_opts = HookManager.run("gpg-options", {:operation => "encrypt", :options => gpg_opts}) || gpg_opts recipients = to + [from] recipients = HookManager.run("gpg-expand-keys", { :recipients => recipients }) || recipients begin if GPGME.respond_to?('encrypt') cipher = GPGME.encrypt(recipients, format_payload(payload), gpg_opts) else crypto = GPGME::Crypto.new gpg_opts[:recipients] = recipients cipher = crypto.encrypt(format_payload(payload), gpg_opts).read end rescue GPGME::Error => exc raise Error, gpgme_exc_msg(exc.message) end # if the key (or gpg-agent) is not available GPGME does not complain # but just returns a zero length string. Let's catch that if cipher.length == 0 raise Error, gpgme_exc_msg("GPG failed to generate cipher text: check that gpg-agent is running and your key is available.") end encrypted_payload = RMail::Message.new encrypted_payload.header["Content-Type"] = "application/octet-stream" encrypted_payload.header["Content-Disposition"] = 'inline; filename="msg.asc"' encrypted_payload.body = cipher control = RMail::Message.new control.header["Content-Type"] = "application/pgp-encrypted" control.header["Content-Disposition"] = "attachment" control.body = "Version: 1\n" envelope = RMail::Message.new envelope.header["Content-Type"] = 'multipart/encrypted; protocol=application/pgp-encrypted' envelope.add_part control envelope.add_part encrypted_payload envelope end def sign_and_encrypt from, to, payload encrypt from, to, payload, true end def verified_ok? verify_result valid = true unknown = false all_output_lines = [] all_trusted = true unknown_fingerprint = nil verify_result.signatures.each do |signature| output_lines, trusted, unknown_fingerprint = sig_output_lines signature all_output_lines << output_lines all_output_lines.flatten! all_trusted &&= trusted err_code = GPGME::gpgme_err_code(signature.status) if err_code == GPGME::GPG_ERR_BAD_SIGNATURE valid = false elsif err_code != GPGME::GPG_ERR_NO_ERROR valid = false unknown = true end end if valid || !unknown summary_line = simplify_sig_line(verify_result.signatures[0].to_s.dup, all_trusted) end if all_output_lines.length == 0 Chunk::CryptoNotice.new :valid, "Encrypted message wasn't signed", all_output_lines elsif valid if all_trusted Chunk::CryptoNotice.new(:valid, summary_line, all_output_lines) else Chunk::CryptoNotice.new(:valid_untrusted, summary_line, all_output_lines) end elsif !unknown Chunk::CryptoNotice.new(:invalid, summary_line, all_output_lines) elsif unknown_fingerprint Chunk::CryptoNotice.new(:unknown_key, "Unable to determine validity of cryptographic signature", all_output_lines, unknown_fingerprint) else unknown_status all_output_lines end end def verify payload, signature, detached=true # both RubyMail::Message objects return unknown_status(@not_working_reason) unless @not_working_reason.nil? gpg_opts = {:protocol => GPGME::PROTOCOL_OpenPGP} gpg_opts = HookManager.run("gpg-options", {:operation => "verify", :options => gpg_opts}) || gpg_opts ctx = GPGME::Ctx.new(gpg_opts) sig_data = GPGME::Data.from_str signature.decode if detached signed_text_data = GPGME::Data.from_str(format_payload(payload)) plain_data = nil else signed_text_data = nil if GPGME::Data.respond_to?('empty') plain_data = GPGME::Data.empty else plain_data = GPGME::Data.empty! end end begin ctx.verify(sig_data, signed_text_data, plain_data) rescue GPGME::Error => exc return unknown_status [gpgme_exc_msg(exc.message)] end begin self.verified_ok? ctx.verify_result rescue ArgumentError => exc return unknown_status [gpgme_exc_msg(exc.message)] end end ## returns decrypted_message, status, desc, lines def decrypt payload, armor=false # a RubyMail::Message object return unknown_status(@not_working_reason) unless @not_working_reason.nil? gpg_opts = {:protocol => GPGME::PROTOCOL_OpenPGP} gpg_opts = HookManager.run("gpg-options", {:operation => "decrypt", :options => gpg_opts}) || gpg_opts ctx = GPGME::Ctx.new(gpg_opts) cipher_data = GPGME::Data.from_str(format_payload(payload)) if GPGME::Data.respond_to?('empty') plain_data = GPGME::Data.empty else plain_data = GPGME::Data.empty! end begin ctx.decrypt_verify(cipher_data, plain_data) rescue GPGME::Error => exc return Chunk::CryptoNotice.new(:invalid, "This message could not be decrypted", gpgme_exc_msg(exc.message)) end begin sig = self.verified_ok? ctx.verify_result rescue ArgumentError => exc sig = unknown_status [gpgme_exc_msg(exc.message)] end plain_data.seek(0, IO::SEEK_SET) output = plain_data.read output.transcode(Encoding::ASCII_8BIT, output.encoding) ## TODO: test to see if it is still necessary to do a 2nd run if verify ## fails. # ## check for a valid signature in an extra run because gpg aborts if the ## signature cannot be verified (but it is still able to decrypt) #sigoutput = run_gpg "#{payload_fn.path}" #sig = self.old_verified_ok? sigoutput, $? if armor msg = RMail::Message.new # Look for Charset, they are put before the base64 crypted part charsets = payload.body.split("\n").grep(/^Charset:/) if !charsets.empty? and charsets[0] =~ /^Charset: (.+)$/ output.transcode($encoding, $1) end msg.body = output else # It appears that some clients use Windows new lines - CRLF - but RMail # splits the body and header on "\n\n". So to allow the parse below to # succeed, we will convert the newlines to what RMail expects output = output.gsub(/\r\n/, "\n") # This is gross. This decrypted payload could very well be a multipart # element itself, as opposed to a simple payload. For example, a # multipart/signed element, like those generated by Mutt when encrypting # and signing a message (instead of just clearsigning the body). # Supposedly, decrypted_payload being a multipart element ought to work # out nicely because Message::multipart_encrypted_to_chunks() runs the # decrypted message through message_to_chunks() again to get any # children. However, it does not work as intended because these inner # payloads need not carry a MIME-Version header, yet they are fed to # RMail as a top-level message, for which the MIME-Version header is # required. This causes for the part not to be detected as multipart, # hence being shown as an attachment. If we detect this is happening, # we force the decrypted payload to be interpreted as MIME. msg = RMail::Parser.read output if msg.header.content_type =~ %r{^multipart/} && !msg.multipart? output = "MIME-Version: 1.0\n" + output output.fix_encoding! msg = RMail::Parser.read output end end notice = Chunk::CryptoNotice.new :valid, "This message has been decrypted for display" [notice, sig, msg] end def retrieve fingerprint require 'net/http' uri = URI($config[:keyserver_url] || KEYSERVER_URL) unless uri.scheme == "http" and not uri.host.nil? return "Invalid url: #{uri}" end fingerprint = "0x" + fingerprint unless fingerprint[0..1] == "0x" params = {op: "get", search: fingerprint} uri.query = URI.encode_www_form(params) begin res = Net::HTTP.get_response(uri) rescue SocketError # Host doesn't exist or we couldn't connect end return "Couldn't get key from keyserver at this address: #{uri}" unless res.is_a?(Net::HTTPSuccess) match = KEY_PATTERN.match(res.body) return "No key found" unless match && match.length > 0 GPGME::Key.import(match[0]) return nil end private def unknown_status lines=[] Chunk::CryptoNotice.new :unknown, "Unable to determine validity of cryptographic signature", lines end def gpgme_exc_msg msg err_msg = "Exception in GPGME call: #{msg}" #info err_msg err_msg end ## here's where we munge rmail output into the format that signed/encrypted ## PGP/GPG messages should be def format_payload payload payload.to_s.gsub(/(^|[^\r])\n/, "\\1\r\n") end # remove the hex key_id and info in () def simplify_sig_line sig_line, trusted sig_line.sub!(/from [0-9A-F]{16} /, "from ") if !trusted sig_line.sub!(/Good signature/, "Good (untrusted) signature") end sig_line end def sig_output_lines signature # It appears that the signature.to_s call can lead to a EOFError if # the key is not found. So start by looking for the key. ctx = GPGME::Ctx.new begin from_key = ctx.get_key(signature.fingerprint) if GPGME::gpgme_err_code(signature.status) == GPGME::GPG_ERR_GENERAL first_sig = "General error on signature verification for #{signature.fingerprint}" elsif signature.to_s first_sig = signature.to_s.sub(/from [0-9A-F]{16} /, 'from "') + '"' else first_sig = "Unknown error or empty signature" end rescue EOFError from_key = nil first_sig = "No public key available for #{signature.fingerprint}" unknown_fpr = signature.fingerprint end time_line = "Signature made " + signature.timestamp.strftime("%a %d %b %Y %H:%M:%S %Z") + " using " + key_type(from_key, signature.fingerprint) + "key ID " + signature.fingerprint[-8..-1] output_lines = [time_line, first_sig] trusted = false if from_key # first list all the uids if from_key.uids.length > 1 aka_list = from_key.uids[1..-1] aka_list.each { |aka| output_lines << ' aka "' + aka.uid + '"' } end # now we want to look at the trust of that key if signature.validity != GPGME::GPGME_VALIDITY_FULL && signature.validity != GPGME::GPGME_VALIDITY_MARGINAL output_lines << "WARNING: This key is not certified with a trusted signature!" output_lines << "There is no indication that the signature belongs to the owner" output_lines << "Full fingerprint is: " + (0..9).map {|i| signature.fpr[(i*4),4]}.join(":") else trusted = true end # finally, run the hook output_lines << HookManager.run("sig-output", {:signature => signature, :from_key => from_key}) end return output_lines, trusted, unknown_fpr end def key_type key, fpr return "" if key.nil? subkey = key.subkeys.find {|subkey| subkey.fpr == fpr || subkey.keyid == fpr } return "" if subkey.nil? case subkey.pubkey_algo when GPGME::PK_RSA then "RSA " when GPGME::PK_DSA then "DSA " when GPGME::PK_ELG then "ElGamel " when GPGME::PK_ELG_E then "ElGamel " else "unknown key type (#{subkey.pubkey_algo}) " end end # logic is: # if gpgkey set for this account, then use that # elsif only one account, then leave blank so gpg default will be user # else set --local-user from_email_address # NOTE: multiple signers doesn't seem to work with gpgme (2.0.2, 1.0.8) # def gen_sign_user_opts from account = AccountManager.account_for from account ||= AccountManager.default_account if !account.gpgkey.nil? opts = {:signer => account.gpgkey} elsif AccountManager.user_emails.length == 1 # only one account opts = {} else opts = {:signer => from} end opts end end end sup-1.1/lib/sup/mode.rb0000644000004100000410000000635514246427237015064 0ustar www-datawww-datarequire 'open3' module Redwood class Mode attr_accessor :buffer @@keymaps = {} def self.register_keymap keymap=nil, &b keymap = Keymap.new(&b) if keymap.nil? @@keymaps[self] = keymap end def self.keymap @@keymaps[self] || register_keymap end def self.keymaps @@keymaps end def initialize @buffer = nil end def self.make_name s; s.gsub(/.*::/, "").camel_to_hyphy; end def name; Mode.make_name self.class.name; end def self.load_all_modes dir Dir[File.join(dir, "*.rb")].each do |f| $stderr.puts "## loading mode #{f}" require f end end def killable?; true; end def unsaved?; false end def draw; end def focus; end def blur; end def cancel_search!; end def in_search?; false end def status; ""; end def resize rows, cols; end def cleanup @buffer = nil end def resolve_input c self.class.ancestors.each do |klass| # try all keymaps in order of ancestry next unless @@keymaps.member?(klass) action = BufferManager.resolve_input_with_keymap c, @@keymaps[klass] return action if action end nil end def handle_input c action = resolve_input(c) or return false send action true end def help_text used_keys = {} self.class.ancestors.map do |klass| km = @@keymaps[klass] or next title = "Keybindings from #{Mode.make_name klass.name}" s = < e m = "Error writing file: #{e.message}" info m BufferManager.flash m false end end def pipe_to_process command begin Open3.popen3(command) do |input, output, error| err, data, * = IO.select [error], [input], nil unless err.empty? message = err.first.read if message =~ /^\s*$/ warn "error running #{command} (but no error message)" BufferManager.flash "Error running #{command}!" else warn "error running #{command}: #{message}" BufferManager.flash "Error: #{message}" end return nil, false end data = data.first data.sync = false # buffer input yield data data.close # output will block unless input is closed ## BUG?: shows errors or output but not both.... data, * = IO.select [output, error], nil, nil data = data.first if data.eof BufferManager.flash "'#{command}' done!" return nil, true else return data.read, true end end rescue Errno::ENOENT # If the command is invalid return nil, false end end end end sup-1.1/lib/sup/maildir.rb0000644000004100000410000001733714246427237015563 0ustar www-datawww-datarequire 'uri' require 'set' module Redwood class Maildir < Source include SerializeLabelsNicely MYHOSTNAME = Socket.gethostname ## remind me never to use inheritance again. yaml_properties :uri, :usual, :archived, :sync_back, :id, :labels def initialize uri, usual=true, archived=false, sync_back=true, id=nil, labels=[] super uri, usual, archived, id @expanded_uri = Source.expand_filesystem_uri(uri) parts = /^([a-zA-Z0-9]*:(\/\/)?)(.*)/.match @expanded_uri if parts prefix = parts[1] @path = parts[3] uri = URI(prefix + Source.encode_path_for_uri(@path)) else uri = URI(Source.encode_path_for_uri @path) @path = uri.path end raise ArgumentError, "not a maildir URI" unless uri.scheme == "maildir" raise ArgumentError, "maildir URI cannot have a host: #{uri.host}" if uri.host raise ArgumentError, "maildir URI must have a path component" unless uri.path @sync_back = sync_back # sync by default if not specified @sync_back = true if @sync_back.nil? @dir = URI.decode_www_form_component uri.path @labels = Set.new(labels || []) @mutex = Mutex.new @ctimes = { 'cur' => Time.at(0), 'new' => Time.at(0) } end def file_path; @dir end def self.suggest_labels_for path; [] end def is_source_for? uri; super || (uri == @expanded_uri); end def supported_labels? [:draft, :starred, :forwarded, :replied, :unread, :deleted] end def sync_back_enabled? @sync_back end def store_message date, from_email, &block stored = false new_fn = new_maildir_basefn + ':2,S' Dir.chdir(@dir) do |d| tmp_path = File.join(@dir, 'tmp', new_fn) new_path = File.join(@dir, 'new', new_fn) begin sleep 2 if File.stat(tmp_path) File.stat(tmp_path) rescue Errno::ENOENT #this is what we want. begin File.open(tmp_path, 'wb') do |f| yield f #provide a writable interface for the caller f.fsync end File.safe_link tmp_path, new_path stored = true ensure File.unlink tmp_path if File.exist? tmp_path end end #rescue Errno... end #Dir.chdir stored end def each_raw_message_line id with_file_for(id) do |f| until f.eof? yield f.gets end end end def load_header id with_file_for(id) { |f| parse_raw_email_header f } end def load_message id with_file_for(id) { |f| RMail::Parser.read f } end def sync_back id, labels synchronize do debug "syncing back maildir message #{id} with flags #{labels.to_a}" flags = maildir_reconcile_flags id, labels maildir_mark_file id, flags end end def raw_header id ret = "" with_file_for(id) do |f| until f.eof? || (l = f.gets) =~ /^$/ ret += l end end ret end def raw_message id with_file_for(id) { |f| f.read } end ## XXX use less memory def poll added = [] deleted = [] updated = [] @ctimes.each do |d,prev_ctime| subdir = File.join @dir, d debug "polling maildir #{subdir}" raise FatalSourceError, "#{subdir} not a directory" unless File.directory? subdir ctime = File.ctime subdir next if prev_ctime >= ctime @ctimes[d] = ctime old_ids = benchmark(:maildir_read_index) { Index.instance.enum_for(:each_source_info, self.id, "#{d}/").to_a } new_ids = benchmark(:maildir_read_dir) { Dir.open(subdir).select { |f| !File.directory? f}.map { |x| File.join(d,File.basename(x)) }.sort } added += new_ids - old_ids deleted += old_ids - new_ids debug "#{old_ids.size} in index, #{new_ids.size} in filesystem" end ## find updated mails by checking if an id is in both added and ## deleted arrays, meaning that its flags changed or that it has ## been moved, these ids need to be removed from added and deleted add_to_delete = del_to_delete = [] map = Hash.new { |hash, key| hash[key] = [] } deleted.each do |id_del| map[maildir_data(id_del)[0]].push id_del end added.each do |id_add| map[maildir_data(id_add)[0]].each do |id_del| updated.push [ id_del, id_add ] add_to_delete.push id_add del_to_delete.push id_del end end added -= add_to_delete deleted -= del_to_delete debug "#{added.size} added, #{deleted.size} deleted, #{updated.size} updated" total_size = added.size+deleted.size+updated.size added.each_with_index do |id,i| yield :add, :info => id, :labels => @labels + maildir_labels(id) + [:inbox], :progress => i.to_f/total_size end deleted.each_with_index do |id,i| yield :delete, :info => id, :progress => (i.to_f+added.size)/total_size end updated.each_with_index do |id,i| yield :update, :old_info => id[0], :new_info => id[1], :labels => @labels + maildir_labels(id[1]), :progress => (i.to_f+added.size+deleted.size)/total_size end nil end def labels? id maildir_labels id end def maildir_labels id (seen?(id) ? [] : [:unread]) + (trashed?(id) ? [:deleted] : []) + (flagged?(id) ? [:starred] : []) + (passed?(id) ? [:forwarded] : []) + (replied?(id) ? [:replied] : []) + (draft?(id) ? [:draft] : []) end def draft? id; maildir_data(id)[2].include? "D"; end def flagged? id; maildir_data(id)[2].include? "F"; end def passed? id; maildir_data(id)[2].include? "P"; end def replied? id; maildir_data(id)[2].include? "R"; end def seen? id; maildir_data(id)[2].include? "S"; end def trashed? id; maildir_data(id)[2].include? "T"; end def valid? id File.exist? File.join(@dir, id) end private def new_maildir_basefn Kernel::srand() "#{Time.now.to_i.to_s}.#{$$}#{Kernel.rand(1000000)}.#{MYHOSTNAME}" end def with_file_for id fn = File.join(@dir, id) begin File.open(fn, 'rb') { |f| yield f } rescue SystemCallError, IOError => e raise FatalSourceError, "Problem reading file for id #{id.inspect}: #{fn.inspect}: #{e.message}." end end def maildir_data id id = File.basename id # Flags we recognize are DFPRST id =~ %r{^([^:]+):([12]),([A-Za-z]*)$} [($1 || id), ($2 || "2"), ($3 || "")] end def maildir_reconcile_flags id, labels new_flags = Set.new( maildir_data(id)[2].each_char ) # Set flags based on labels for the six flags we recognize if labels.member? :draft then new_flags.add?( "D" ) else new_flags.delete?( "D" ) end if labels.member? :starred then new_flags.add?( "F" ) else new_flags.delete?( "F" ) end if labels.member? :forwarded then new_flags.add?( "P" ) else new_flags.delete?( "P" ) end if labels.member? :replied then new_flags.add?( "R" ) else new_flags.delete?( "R" ) end if not labels.member? :unread then new_flags.add?( "S" ) else new_flags.delete?( "S" ) end if labels.member? :deleted or labels.member? :killed then new_flags.add?( "T" ) else new_flags.delete?( "T" ) end ## Flags must be stored in ASCII order according to Maildir ## documentation new_flags.to_a.sort.join end def maildir_mark_file orig_path, flags @mutex.synchronize do new_base = (flags.include?("S")) ? "cur" : "new" md_base, md_ver, md_flags = maildir_data orig_path return if md_flags == flags new_loc = File.join new_base, "#{md_base}:#{md_ver},#{flags}" orig_path = File.join @dir, orig_path new_path = File.join @dir, new_loc tmp_path = File.join @dir, "tmp", "#{md_base}:#{md_ver},#{flags}" File.safe_link orig_path, tmp_path File.unlink orig_path File.safe_link tmp_path, new_path File.unlink tmp_path new_loc end end end end sup-1.1/lib/sup/interactive_lock.rb0000644000004100000410000000515014246427237017455 0ustar www-datawww-datarequire 'fileutils' module Redwood ## wrap a nice interactive layer on top of anything that has a #lock method ## which throws a LockError which responds to #user, #host, #mtim, #pname, and ## #pid. module InteractiveLock def pluralize number_of, kind; "#{number_of} #{kind}" + (number_of == 1 ? "" : "s") end def time_ago_in_words time secs = (Time.now - time).to_i mins = secs / 60 time = if mins == 0 pluralize secs, "second" else pluralize mins, "minute" end end DELAY = 5 # seconds def lock_interactively stream=$stderr begin Index.lock rescue Index::LockError => e begin Process.kill 0, e.pid.to_i # 0 signal test the existence of PID stream.puts < e stream.puts "I couldn't lock the index. The lockfile might just be stale." stream.print "Should I just remove it and continue? (y/n) " stream.flush if $stdin.gets =~ /^\s*y(es)?\s*$/i begin FileUtils.rm e.path rescue Errno::ENOENT stream.puts "The lockfile doesn't exists. We continue." end stream.puts "Let's try that one more time." begin Index.lock rescue Index::LockError => e stream.puts "I couldn't unlock the index." return false end return true end end end rescue Errno::ESRCH # no such process stream.puts "I couldn't lock the index. The lockfile might just be stale." begin FileUtils.rm e.path rescue Errno::ENOENT stream.puts "The lockfile doesn't exists. We continue." end stream.puts "Let's try that one more time." begin sleep DELAY Index.lock rescue Index::LockError => e stream.puts "I couldn't unlock the index." return false end return true end stream.puts "Sorry, couldn't unlock the index." return false end return true end end end sup-1.1/lib/sup/account.rb0000644000004100000410000000444114246427237015566 0ustar www-datawww-datamodule Redwood class Account < Person attr_accessor :sendmail, :signature, :gpgkey def initialize h raise ArgumentError, "no name for account" unless h[:name] raise ArgumentError, "no email for account" unless h[:email] super h[:name], h[:email] @sendmail = h[:sendmail] @signature = h[:signature] @gpgkey = h[:gpgkey] end # Default sendmail command for bouncing mail, # deduced from #sendmail def bounce_sendmail sendmail.sub(/\s(\-(ti|it|t))\b/) do |match| case $1 when '-t' then '' else ' -i' end end end end class AccountManager include Redwood::Singleton attr_accessor :default_account def initialize accounts @email_map = {} @accounts = {} @regexen = {} @default_account = nil add_account accounts[:default], true accounts.each { |k, v| add_account v, false unless k == :default } end def user_accounts; @accounts.keys; end def user_emails; @email_map.keys.select { |e| String === e }; end ## must be called first with the default account. fills in missing ## values from the default account. def add_account hash, default=false raise ArgumentError, "no email specified for account" unless hash[:email] unless default [:name, :sendmail, :signature, :gpgkey].each { |k| hash[k] ||= @default_account.send(k) } end hash[:alternates] ||= [] fail "alternative emails are not an array: #{hash[:alternates]}" unless hash[:alternates].kind_of? Array [:name, :signature].each { |x| hash[x] ? hash[x].fix_encoding! : nil } a = Account.new hash @accounts[a] = true if default raise ArgumentError, "multiple default accounts" if @default_account @default_account = a end ([hash[:email]] + hash[:alternates]).each do |email| next if @email_map.member? email @email_map[email] = a end hash[:regexen].each do |re| @regexen[Regexp.new(re)] = a end if hash[:regexen] end def is_account? p; is_account_email? p.email end def is_account_email? email; !account_for(email).nil? end def account_for email if(a = @email_map[email]) a else @regexen.argfind { |re, a| re =~ email && a } end end def full_address_for email a = account_for email Person.full_address a.name, email end end end sup-1.1/lib/sup/source.rb0000644000004100000410000001643114246427237015434 0ustar www-datawww-datarequire "sup/rfc2047" require "monitor" module Redwood class SourceError < StandardError def initialize *a raise "don't instantiate me!" if SourceError.is_a?(self.class) super end end class OutOfSyncSourceError < SourceError; end class FatalSourceError < SourceError; end class Source ## Implementing a new source should be easy, because Sup only needs ## to be able to: ## 1. See how many messages it contains ## 2. Get an arbitrary message ## 3. (optional) see whether the source has marked it read or not ## ## In particular, Sup doesn't need to move messages, mark them as ## read, delete them, or anything else. (Well, it's nice to be able ## to delete them, but that is optional.) ## ## Messages are identified internally based on the message id, and stored ## with an unique document id. Along with the message, source information ## that can contain arbitrary fields (set up by the source) is stored. This ## information will be passed back to the source when a message in the ## index (Sup database) needs to be identified to its source, e.g. when ## re-reading or modifying a unique message. ## ## To write a new source, subclass this class, and implement: ## ## - initialize ## - load_header offset ## - load_message offset ## - raw_header offset ## - raw_message offset ## - store_message (optional) ## - poll (loads new messages) ## - go_idle (optional) ## ## All exceptions relating to accessing the source must be caught ## and rethrown as FatalSourceErrors or OutOfSyncSourceErrors. ## OutOfSyncSourceErrors should be used for problems that a call to ## sup-sync will fix (namely someone's been playing with the source ## from another client); FatalSourceErrors can be used for anything ## else (e.g. the imap server is down or the maildir is missing.) ## ## Finally, be sure the source is thread-safe, since it WILL be ## pummelled from multiple threads at once. ## ## Examples for you to look at: mbox.rb and maildir.rb. bool_accessor :usual, :archived attr_reader :uri, :usual attr_accessor :id def initialize uri, usual=true, archived=false, id=nil raise ArgumentError, "id must be an integer: #{id.inspect}" unless id.is_a? Integer if id @uri = uri @usual = usual @archived = archived @id = id @poll_lock = Monitor.new end ## overwrite me if you have a disk incarnation def file_path; nil end def to_s; @uri.to_s; end def == o; o.uri == uri; end def is_source_for? uri; uri == @uri; end def read?; false; end ## release resources that are easy to reacquire. it is called ## after processing a source (e.g. polling) to prevent resource ## leaks (esp. file descriptors). def go_idle; end ## Returns an array containing all the labels that are natively ## supported by this source def supported_labels?; [] end ## Returns an array containing all the labels that are currently in ## the location filename def labels? info; [] end ## Yields values of the form [Symbol, Hash] ## add: info, labels, progress ## delete: info, progress def poll unimplemented end def valid? info true end def synchronize &block @poll_lock.synchronize(&block) end def try_lock acquired = @poll_lock.try_enter if acquired debug "lock acquired for: #{self}" else debug "could not acquire lock for: #{self}" end acquired end def unlock @poll_lock.exit debug "lock released for: #{self}" end ## utility method to read a raw email header from an IO stream and turn it ## into a hash of key-value pairs. minor special semantics for certain headers. ## ## THIS IS A SPEED-CRITICAL SECTION. Everything you do here will have a ## significant effect on Sup's processing speed of email from ALL sources. ## Little things like string interpolation, regexp interpolation, += vs <<, ## all have DRAMATIC effects. BE CAREFUL WHAT YOU DO! def self.parse_raw_email_header f header = {} last = nil while(line = f.gets) case line ## these three can occur multiple times, and we want the first one when /^(Delivered-To|X-Original-To|Envelope-To):\s*(.*?)\s*$/i; header[last = $1.downcase] ||= $2 ## regular header: overwrite (not that we should see more than one) ## TODO: figure out whether just using the first occurrence changes ## anything (which would simplify the logic slightly) when /^([^:\s]+):\s*(.*?)\s*$/i; header[last = $1.downcase] = $2 when /^\r*$/; break # blank line signifies end of header else if last header[last] << " " unless header[last].empty? header[last] << line.strip end end end %w(subject from to cc bcc).each do |k| v = header[k] or next next unless Rfc2047.is_encoded? v header[k] = begin Rfc2047.decode_to $encoding, v rescue Errno::EINVAL, Iconv::InvalidEncoding, Iconv::IllegalSequence #debug "warning: error decoding RFC 2047 header (#{e.class.name}): #{e.message}" v end end header end protected ## convenience function def parse_raw_email_header f; self.class.parse_raw_email_header f end def Source.expand_filesystem_uri uri uri.gsub "~", File.expand_path("~") end def Source.encode_path_for_uri path path.gsub(Regexp.new("[#{Regexp.quote(URI_ENCODE_CHARS)}]")) { |c| c.each_byte.map { |x| sprintf("%%%02X", x) }.join } end end ## if you have a @labels instance variable, include this ## to serialize them nicely as an array, rather than as a ## nasty set. module SerializeLabelsNicely def before_marshal # can return an object c = clone c.instance_eval { @labels = (@labels.to_a.map { |l| l.to_s }).sort } c end def after_unmarshal! @labels = Set.new(@labels.to_a.map { |s| s.to_sym }) end end class SourceManager include Redwood::Singleton def initialize @sources = {} @sources_dirty = false @source_mutex = Monitor.new end def [](id) @source_mutex.synchronize { @sources[id] } end def add_source source @source_mutex.synchronize do raise "duplicate source!" if @sources.include? source @sources_dirty = true max = @sources.max_of { |id, s| s.is_a?(DraftLoader) || s.is_a?(SentLoader) ? 0 : id } source.id ||= (max || 0) + 1 ##source.id += 1 while @sources.member? source.id @sources[source.id] = source end end def sources ## favour the inbox by listing non-archived sources first @source_mutex.synchronize { @sources.values }.sort_by { |s| s.id }.partition { |s| !s.archived? }.flatten end def source_for uri expanded_uri = Source.expand_filesystem_uri(uri) sources.find { |s| s.is_source_for? expanded_uri } end def usual_sources; sources.find_all { |s| s.usual? }; end def unusual_sources; sources.find_all { |s| !s.usual? }; end def load_sources fn=Redwood::SOURCE_FN source_array = Redwood::load_yaml_obj(fn) || [] @source_mutex.synchronize do @sources = Hash[*(source_array).map { |s| [s.id, s] }.flatten] @sources_dirty = false end end def save_sources fn=Redwood::SOURCE_FN, force=false @source_mutex.synchronize do if @sources_dirty || force Redwood::save_yaml_obj sources, fn, false, true end @sources_dirty = false end end end end sup-1.1/lib/sup/label.rb0000644000004100000410000000373614246427237015217 0ustar www-datawww-data# encoding: utf-8 module Redwood class LabelManager include Redwood::Singleton ## labels that have special semantics. user will be unable to ## add/remove these via normal label mechanisms. RESERVED_LABELS = [ :starred, :spam, :draft, :unread, :killed, :sent, :deleted, :inbox, :attachment, :forwarded, :replied ] ## labels that will typically be hidden from the user HIDDEN_RESERVED_LABELS = [ :starred, :unread, :attachment, :forwarded, :replied ] def initialize fn @fn = fn labels = if File.exist? fn IO.readlines(fn).map { |x| x.chomp.intern } else [] end @labels = {} @new_labels = {} @modified = false labels.each { |t| @labels[t] = true } end def new_label? l; @new_labels.include?(l) end ## all labels user-defined and system, ordered ## nicely and converted to pretty strings. use #label_for to recover ## the original label. def all_labels ## uniq's only necessary here because of certain upgrade issues (RESERVED_LABELS + @labels.keys).uniq end ## all user-defined labels, ordered ## nicely and converted to pretty strings. use #label_for to recover ## the original label. def user_defined_labels @labels.keys end ## reverse the label->string mapping, for convenience! def string_for l if RESERVED_LABELS.include? l l.to_s.capitalize else l.to_s end end def label_for s l = s.intern l2 = s.downcase.intern if RESERVED_LABELS.include? l2 l2 else l end end def << t raise ArgumentError, "expecting a symbol" unless t.is_a? Symbol unless @labels.member?(t) || RESERVED_LABELS.member?(t) @labels[t] = true @new_labels[t] = true @modified = true end end def delete t if @labels.delete(t) @modified = true end end def save return unless @modified File.open(@fn, "w:UTF-8") { |f| f.puts @labels.keys.sort_by { |l| l.to_s } } @new_labels = {} end end end sup-1.1/lib/sup/contact.rb0000644000004100000410000000333414246427237015565 0ustar www-datawww-data# encoding: utf-8 module Redwood class ContactManager include Redwood::Singleton def initialize fn @fn = fn ## maintain the mapping between people and aliases. for contacts without ## aliases, there will be no @a2p entry, so @p2a.keys should be treated ## as the canonical list of contacts. @p2a = {} # person to alias @a2p = {} # alias to person @e2p = {} # email to person if File.exist? fn IO.foreach(fn) do |l| l =~ /^([^:]*): (.*)$/ or raise "can't parse #{fn} line #{l.inspect}" aalias, addr = $1, $2 update_alias Person.from_address(addr), aalias end end end def contacts; @p2a.keys end def contacts_with_aliases; @a2p.values.uniq end def update_alias person, aalias=nil ## Deleting old data if it exists old_aalias = @p2a[person] if old_aalias @a2p.delete old_aalias @e2p.delete person.email end ## Update with new data @p2a[person] = aalias unless aalias.nil? || aalias.empty? @a2p[aalias] = person @e2p[person.email] = person end end ## this may not actually be called anywhere, since we still keep contacts ## around without aliases to override any fullname changes. def drop_contact person aalias = @p2a[person] @p2a.delete person @e2p.delete person.email @a2p.delete aalias if aalias end def contact_for aalias; @a2p[aalias] end def alias_for person; @p2a[person] end def person_for email; @e2p[email] end def is_aliased_contact? person; !@p2a[person].nil? end def save File.open(@fn, "w:UTF-8") do |f| @p2a.sort_by { |(p, a)| [p.full_address, a] }.each do |(p, a)| f.puts "#{a || ''}: #{p.full_address}" end end end end end sup-1.1/lib/sup/textfield.rb0000644000004100000410000001625714246427237016132 0ustar www-datawww-datarequire 'sup/util/ncurses' module Redwood ## a fully-functional text field supporting completions, expansions, ## history--everything! ## ## writing this fucking sucked. if you thought ncurses was some 1970s ## before-people-knew-how-to-program bullshit, wait till you see ## ncurses forms. ## ## completion comments: completion is done emacs-style, and mostly ## depends on outside support, as we merely signal the existence of a ## new set of completions to show (#new_completions?) or that the ## current list of completions should be rolled if they're too large ## to fill the screen (#roll_completions?). ## ## in sup, completion support is implemented through BufferManager#ask ## and CompletionMode. class TextField include Ncurses::Form::DriverHelpers def initialize @i = nil @history = [] @completion_block = nil reset_completion_state end bool_reader :new_completions, :roll_completions attr_reader :completions def value; @value || get_cursed_value end def activate window, y, x, width, question, default=nil, &block @w, @y, @x, @width = window, y, x, width @question = question @completion_block = block @field = Ncurses::Form.new_field 1, @width - question.length, @y, @x + question.length, 0, 0 if @field.respond_to? :opts_off @field.opts_off Ncurses::Form::O_STATIC @field.opts_off Ncurses::Form::O_BLANK end @form = Ncurses::Form.new_form [@field] @value = default || '' Ncurses::Form.post_form @form set_cursed_value @value end def position_cursor @w.attrset Colormap.color_for(:none) @w.mvaddstr @y, 0, @question Ncurses.curs_set 1 form_driver_key Ncurses::Form::REQ_END_FIELD form_driver_key Ncurses::Form::REQ_NEXT_CHAR if @value && @value =~ / $/ # fucking RETARDED end def deactivate reset_completion_state @form.unpost_form @form.free_form @field.free_field @field = nil Ncurses.curs_set 0 end def handle_input c ## short-circuit exit paths case c.code when Ncurses::KEY_ENTER # submit! @value = get_cursed_value @history.push @value unless @value =~ /^\s*$/ @i = @history.size return false when Ncurses::KEY_CANCEL # cancel @value = nil return false when Ncurses::KEY_TAB # completion return true unless @completion_block if @completions.empty? v = get_cursed_value c = @completion_block.call v if c.size > 0 @value = c.map { |full, short| full }.shared_prefix(true) set_cursed_value @value position_cursor end if c.size > 1 @completions = c @new_completions = true @roll_completions = false end else @new_completions = false @roll_completions = true end return true end reset_completion_state @value = nil # ctrl_c: control char ctrl_c = case c.keycode # only test for keycodes when Ncurses::KEY_LEFT Ncurses::Form::REQ_PREV_CHAR when Ncurses::KEY_RIGHT Ncurses::Form::REQ_NEXT_CHAR when Ncurses::KEY_DC Ncurses::Form::REQ_DEL_CHAR when Ncurses::KEY_BACKSPACE Ncurses::Form::REQ_DEL_PREV when Ncurses::KEY_HOME nop Ncurses::Form::REQ_BEG_FIELD when Ncurses::KEY_END Ncurses::Form::REQ_END_FIELD when Ncurses::KEY_UP, Ncurses::KEY_DOWN unless !@i || @history.empty? #debug "history before #{@history.inspect}" @i = @i + (c.is_keycode?(Ncurses::KEY_UP) ? -1 : 1) @i = 0 if @i < 0 @i = @history.size if @i > @history.size @value = @history[@i] || '' #debug "history after #{@history.inspect}" set_cursed_value @value Ncurses::Form::REQ_END_FIELD end else # return other keycode or nil if it's not a keycode c.dumb? ? nil : c.keycode end # handle keysyms # ctrl_c: control char ctrl_c = case c when ?\177 # backspace (octal) Ncurses::Form::REQ_DEL_PREV when ?\C-a # home nop Ncurses::Form::REQ_BEG_FIELD when ?\C-e # end keysym Ncurses::Form::REQ_END_FIELD when ?\C-k Ncurses::Form::REQ_CLR_EOF when ?\C-u set_cursed_value cursed_value_after_point form_driver_key Ncurses::Form::REQ_END_FIELD nop Ncurses::Form::REQ_BEG_FIELD when ?\C-w while action = remove_extra_space form_driver_key action end form_driver_key Ncurses::Form::REQ_PREV_CHAR form_driver_key Ncurses::Form::REQ_DEL_WORD end if ctrl_c.nil? c.replace(ctrl_c).keycode! if ctrl_c # no effect for dumb CharCode form_driver c if c.present? true end private def reset_completion_state @completions = [] @new_completions = @roll_completions = @clear_completions = false end ## ncurses inanity wrapper ## ## DO NOT READ THIS CODE. YOU WILL GO MAD. def get_cursed_value return nil unless @field x = Ncurses.curx form_driver_key Ncurses::Form::REQ_VALIDATION v = @field.field_buffer(0).gsub(/^\s+|\s+$/, "") ## cursor <= end of text if x - @question.length - v.length <= 0 v else # trailing spaces v + (" " * (x - @question.length - v.length)) end # ncurses returns a ASCII-8BIT (binary) string, which # bytes presumably are of current charset encoding. we force_encoding # so that the char representation / string is tagged will be the # system locale and also hopefully the terminal/input encoding. an # incorrectly configured terminal encoding (not matching the system # encoding) will produce erronous results, but will also do that for # a lot of other programs since it is impossible to detect which is # which and what encoding the inputted byte chars are supposed to have. v.force_encoding($encoding).fix_encoding! end def remove_extra_space return nil unless @field form_driver_key Ncurses::Form::REQ_VALIDATION x = Ncurses.curx v = @field.field_buffer(0).gsub(/^\s+|\s+$/, "") v_index = x - @question.length # at start of line if v_index < 1 nil ## cursor <= end of text elsif v_index < v.length # is the character before the cursor a space? if v[v_index-1] == ?\s # if there is a non-space char under cursor then go back if v[v_index] != ?\s Ncurses::Form::REQ_PREV_CHAR # otherwise delete the space else Ncurses::Form::REQ_DEL_PREV end else nil end elsif v_index == v.length # at end of string, with non-space before us nil else # trailing spaces Ncurses::Form::REQ_PREV_CHAR end end def set_cursed_value v v = "" if v.nil? @field.set_field_buffer 0, v end def cursed_value_after_point point = Ncurses.curx - @question.length get_cursed_value[point..-1] end ## this is almost certainly unnecessary, but it's the only way ## i could get ncurses to remember my form's value def nop form_driver_char " " form_driver_key Ncurses::Form::REQ_DEL_PREV end end end sup-1.1/lib/sup/draft.rb0000644000004100000410000000514014246427237015227 0ustar www-datawww-datamodule Redwood class DraftManager include Redwood::Singleton attr_accessor :source def initialize dir @dir = dir @source = nil end def self.source_name; "sup://drafts"; end def self.source_id; 9999; end def new_source; @source = DraftLoader.new; end def write_draft offset = @source.gen_offset fn = @source.fn_for_offset offset File.open(fn, "w:UTF-8") { |f| yield f } PollManager.poll_from @source end def discard m raise ArgumentError, "not a draft: source id #{m.source.id.inspect}, should be #{DraftManager.source_id.inspect} for #{m.id.inspect}" unless m.source.id.to_i == DraftManager.source_id Index.delete m.id File.delete @source.fn_for_offset(m.source_info) rescue Errono::ENOENT UpdateManager.relay self, :single_message_deleted, m end end class DraftLoader < Source attr_accessor :dir yaml_properties def initialize dir=Redwood::DRAFT_DIR Dir.mkdir dir unless File.exist? dir super DraftManager.source_name, true, false @dir = dir @cur_offset = 0 end def properly_initialized? !!(@dir && @cur_offset) end def id; DraftManager.source_id; end def to_s; DraftManager.source_name; end def uri; DraftManager.source_name; end def poll ids = get_ids ids.each do |id| if id >= @cur_offset @cur_offset = id + 1 yield :add, :info => id, :labels => [:draft, :inbox], :progress => 0.0 end end end def gen_offset i = @cur_offset while File.exist? fn_for_offset(i) i += 1 end i end def fn_for_offset o; File.join(@dir, o.to_s); end def load_header offset File.open(fn_for_offset(offset)) { |f| parse_raw_email_header f } end def load_message offset raise SourceError, "Draft not found" unless File.exist? fn_for_offset(offset) File.open fn_for_offset(offset) do |f| RMail::Mailbox::MBoxReader.new(f).each_message do |input| return RMail::Parser.read(input) end end end def raw_header offset ret = "" File.open(fn_for_offset(offset), "r:UTF-8") do |f| until f.eof? || (l = f.gets) =~ /^$/ ret += l end end ret end def each_raw_message_line offset File.open(fn_for_offset(offset), "r:UTF-8") do |f| yield f.gets until f.eof? end end def raw_message offset IO.read(fn_for_offset(offset), :encoding => "UTF-8") end def start_offset; 0; end def end_offset ids = get_ids ids.empty? ? 0 : (ids.last + 1) end private def get_ids Dir.entries(@dir).select { |x| x =~ /^\d+$/ }.map { |x| x.to_i }.sort end end end sup-1.1/lib/sup/message.rb0000644000004100000410000006516214246427237015565 0ustar www-datawww-data# encoding: UTF-8 require 'time' require 'string-scrub' if /^2\.0\./ =~ RUBY_VERSION module Redwood ## a Message is what's threaded. ## ## it is also where the parsing for quotes and signatures is done, but ## that should be moved out to a separate class at some point (because ## i would like, for example, to be able to add in a ruby-talk ## specific module that would detect and link to /ruby-talk:\d+/ ## sequences in the text of an email. (how sweet would that be?) class Message SNIPPET_LEN = 80 RE_PATTERN = /^((re|re[\[\(]\d[\]\)]):\s*)+/i ## some utility methods class << self def normalize_subj s; s.gsub(RE_PATTERN, ""); end def subj_is_reply? s; s =~ RE_PATTERN; end def reify_subj s; subj_is_reply?(s) ? s : "Re: " + s; end end QUOTE_PATTERN = /^\s{0,4}[>|\}]/ BLOCK_QUOTE_PATTERN = /^-----\s*Original Message\s*----+$/ SIG_PATTERN = /(^(- )*-- ?$)|(^\s*----------+\s*$)|(^\s*_________+\s*$)|(^\s*--~--~-)|(^\s*--\+\+\*\*==)/ GPG_SIGNED_START = "-----BEGIN PGP SIGNED MESSAGE-----" GPG_SIGNED_END = "-----END PGP SIGNED MESSAGE-----" GPG_START = "-----BEGIN PGP MESSAGE-----" GPG_END = "-----END PGP MESSAGE-----" GPG_SIG_START = "-----BEGIN PGP SIGNATURE-----" GPG_SIG_END = "-----END PGP SIGNATURE-----" MAX_SIG_DISTANCE = 15 # lines from the end DEFAULT_SUBJECT = "" DEFAULT_SENDER = "(missing sender)" MAX_HEADER_VALUE_SIZE = 4096 attr_reader :id, :date, :from, :subj, :refs, :replytos, :to, :cc, :bcc, :labels, :attachments, :list_address, :recipient_email, :replyto, :list_subscribe, :list_unsubscribe bool_reader :dirty, :source_marked_read, :snippet_contains_encrypted_content attr_accessor :locations ## if you specify a :header, will use values from that. otherwise, ## will try and load the header from the source. def initialize opts @locations = opts[:locations] or raise ArgumentError, "locations can't be nil" @snippet = opts[:snippet] @snippet_contains_encrypted_content = false @have_snippet = !(opts[:snippet].nil? || opts[:snippet].empty?) @labels = Set.new(opts[:labels] || []) @dirty = false @encrypted = false @chunks = nil @attachments = [] ## we need to initialize this. see comments in parse_header as to ## why. @refs = [] #parse_header(opts[:header] || @source.load_header(@source_info)) end def decode_header_field v return unless v return v unless v.is_a? String return unless v.size < MAX_HEADER_VALUE_SIZE # avoid regex blowup on spam ## Header values should be either 7-bit with RFC2047-encoded words ## or UTF-8 as per RFC6532. Replace any invalid high bytes with U+FFFD. Rfc2047.decode_to $encoding, v.dup.force_encoding(Encoding::UTF_8).scrub end def parse_header encoded_header header = SavingHash.new { |k| decode_header_field encoded_header[k] } @id = '' if header["message-id"] mid = header["message-id"] =~ /<(.+?)>/ ? $1 : header["message-id"] @id = sanitize_message_id mid end if (not @id.include? '@') || @id.length < 6 @id = "sup-faked-" + Digest::MD5.hexdigest(raw_header) #from = header["from"] #debug "faking non-existent message-id for message from #{from}: #{id}" end @from = Person.from_address(if header["from"] header["from"] else name = "Sup Auto-generated Fake Sender " #debug "faking non-existent sender for message #@id: #{name}" name end) @date = case(date = header["date"]) when Time date when String begin Time.parse date rescue ArgumentError #debug "faking mangled date header for #{@id} (orig #{header['date'].inspect} gave error: #{e.message})" Time.now end else #debug "faking non-existent date header for #{@id}" Time.now end subj = header["subject"] subj = subj ? subj.fix_encoding! : nil @subj = subj ? subj.gsub(/\s+/, " ").gsub(/\s+$/, "") : DEFAULT_SUBJECT @to = Person.from_address_list header["to"] @cc = Person.from_address_list header["cc"] @bcc = Person.from_address_list header["bcc"] ## before loading our full header from the source, we can actually ## have some extra refs set by the UI. (this happens when the user ## joins threads manually). so we will merge the current refs values ## in here. refs = (header["references"] || "").scan(/<(.+?)>/).map { |x| sanitize_message_id x.first } @refs = (@refs + refs).uniq @replytos = (header["in-reply-to"] || "").scan(/<(.+?)>/).map { |x| sanitize_message_id x.first } @replyto = Person.from_address header["reply-to"] @list_address = if header["list-post"] address = if header["list-post"] =~ /mailto:(.*?)[>\s$]/ $1 elsif header["list-post"] =~ /@/ header["list-post"] # just try the whole fucking thing end address && Person.from_address(address) elsif header["mailing-list"] address = if header["mailing-list"] =~ /list (.*?);/ $1 end address && Person.from_address(address) elsif header["x-mailing-list"] Person.from_address header["x-mailing-list"] end @recipient_email = header["envelope-to"] || header["x-original-to"] || header["delivered-to"] @source_marked_read = header["status"] == "RO" @list_subscribe = header["list-subscribe"] @list_unsubscribe = header["list-unsubscribe"] end ## Expected index entry format: ## :message_id, :subject => String ## :date => Time ## :refs, :replytos => Array of String ## :from => Person ## :to, :cc, :bcc => Array of Person def load_from_index! entry @id = entry[:message_id] @from = entry[:from] @date = entry[:date] @subj = entry[:subject] @to = entry[:to] @cc = entry[:cc] @bcc = entry[:bcc] @refs = (@refs + entry[:refs]).uniq @replytos = entry[:replytos] @replyto = nil @list_address = nil @recipient_email = nil @source_marked_read = false @list_subscribe = nil @list_unsubscribe = nil end def add_ref ref @refs << ref @dirty = true end def remove_ref ref @dirty = true if @refs.delete ref end attr_reader :snippet def is_list_message?; !@list_address.nil?; end def is_draft?; @labels.member? :draft; end def draft_filename raise "not a draft" unless is_draft? source.fn_for_offset source_info end ## sanitize message ids by removing spaces and non-ascii characters. ## also, truncate to 255 characters. all these steps are necessary ## to make the index happy. of course, we probably fuck up a couple ## valid message ids as well. as long as we're consistent, this ## should be fine, though. ## ## also, mostly the message ids that are changed by this belong to ## spam email. ## ## an alternative would be to SHA1 or MD5 all message ids on a regular basis. ## don't tempt me. def sanitize_message_id mid; mid.gsub(/(\s|[^\000-\177])+/, "")[0..254] end def clear_dirty @dirty = false end def has_label? t; @labels.member? t; end def add_label l l = l.to_sym return if @labels.member? l @labels << l @dirty = true end def remove_label l l = l.to_sym return unless @labels.member? l @labels.delete l @dirty = true end def recipients @to + @cc + @bcc end def labels= l raise ArgumentError, "not a set" unless l.is_a?(Set) raise ArgumentError, "not a set of labels" unless l.all? { |ll| ll.is_a?(Symbol) } return if @labels == l @labels = l @dirty = true end def chunks load_from_source! @chunks end def location @locations.find { |x| x.valid? } || raise(OutOfSyncSourceError.new) end def source location.source end def source_info location.info end ## this is called when the message body needs to actually be loaded. def load_from_source! @chunks ||= begin ## we need to re-read the header because it contains information ## that we don't store in the index. actually i think it's just ## the mailing list address (if any), so this is kinda overkill. ## i could just store that in the index, but i think there might ## be other things like that in the future, and i'd rather not ## bloat the index. ## actually, it's also the differentiation between to/cc/bcc, ## so i will keep this. rmsg = location.parsed_message parse_header rmsg.header message_to_chunks rmsg rescue SourceError, SocketError, RMail::EncodingUnsupportedError => e warn_with_location "problem reading message #{id}" debug "could not load message: #{location.inspect}, exception: #{e.inspect}" [Chunk::Text.new(error_message.split("\n"))] rescue Exception => e warn_with_location "problem reading message #{id}" debug "could not load message: #{location.inspect}, exception: #{e.inspect}" raise e end end def reload_from_source! @chunks = nil load_from_source! end def error_message < [Location.new(source, source_info)] m.load_from_source! m end private ## here's where we handle decoding mime attachments. unfortunately ## but unsurprisingly, the world of mime attachments is a bit of a ## mess. as an empiricist, i'm basing the following behavior on ## observed mail rather than on interpretations of rfcs, so probably ## this will have to be tweaked. ## ## the general behavior i want is: ignore content-disposition, at ## least in so far as it suggests something being inline vs being an ## attachment. (because really, that should be the recipient's ## decision to make.) if a mime part is text/plain, OR if the user ## decoding hook converts it, then decode it and display it ## inline. for these decoded attachments, if it has associated ## filename, then make it collapsable and individually saveable; ## otherwise, treat it as regular body text. ## ## everything else is just an attachment and is not displayed ## inline. ## ## so, in contrast to mutt, the user is not exposed to the workings ## of the gruesome slaughterhouse and sausage factory that is a ## mime-encoded message, but need only see the delicious end ## product. def multipart_signed_to_chunks m if m.body.size != 2 warn_with_location "multipart/signed with #{m.body.size} parts (expecting 2)" return end payload, signature = m.body if signature.multipart? warn_with_location "multipart/signed with payload multipart #{payload.multipart?} and signature multipart #{signature.multipart?}" return end ## this probably will never happen if payload.header.content_type && payload.header.content_type.downcase == "application/pgp-signature" warn_with_location "multipart/signed with payload content type #{payload.header.content_type}" return end if signature.header.content_type && signature.header.content_type.downcase != "application/pgp-signature" ## unknown signature type; just ignore. #warn "multipart/signed with signature content type #{signature.header.content_type}" return end [CryptoManager.verify(payload, signature), message_to_chunks(payload)].flatten.compact end def multipart_encrypted_to_chunks m if m.body.size != 2 warn_with_location "multipart/encrypted with #{m.body.size} parts (expecting 2)" return end control, payload = m.body if control.multipart? warn_with_location "multipart/encrypted with control multipart #{control.multipart?} and payload multipart #{payload.multipart?}" return end if payload.header.content_type && payload.header.content_type.downcase != "application/octet-stream" warn_with_location "multipart/encrypted with payload content type #{payload.header.content_type}" return end if control.header.content_type && control.header.content_type.downcase != "application/pgp-encrypted" warn_with_location "multipart/encrypted with control content type #{signature.header.content_type}" return end notice, sig, decryptedm = CryptoManager.decrypt payload if decryptedm # managed to decrypt children = message_to_chunks(decryptedm, true) [notice, sig].compact + children else [notice] end end ## takes a RMail::Message, breaks it into Chunk:: classes. def message_to_chunks m, encrypted=false, sibling_types=[] if m.multipart? chunks = case m.header.content_type.downcase when "multipart/signed" multipart_signed_to_chunks m when "multipart/encrypted" multipart_encrypted_to_chunks m end unless chunks sibling_types = m.body.map { |p| p.header.content_type } chunks = m.body.map { |p| message_to_chunks p, encrypted, sibling_types }.flatten.compact end chunks elsif m.header.content_type && m.header.content_type.downcase == "message/rfc822" encoding = m.header["Content-Transfer-Encoding"] if m.body body = case encoding when "base64" m.body.unpack("m")[0] when "quoted-printable" m.body.unpack("M")[0] when "7bit", "8bit", nil m.body else raise RMail::EncodingUnsupportedError, encoding.inspect end body = body.normalize_whitespace payload = RMail::Parser.read(body) from = payload.header.from.first ? payload.header.from.first.format : "" to = payload.header.to.map { |p| p.format }.join(", ") cc = payload.header.cc.map { |p| p.format }.join(", ") subj = decode_header_field(payload.header.subject) || DEFAULT_SUBJECT subj = Message.normalize_subj(subj.gsub(/\s+/, " ").gsub(/\s+$/, "")) msgdate = payload.header.date from_person = from ? Person.from_address(decode_header_field(from)) : nil to_people = to ? Person.from_address_list(decode_header_field(to)) : nil cc_people = cc ? Person.from_address_list(decode_header_field(cc)) : nil [Chunk::EnclosedMessage.new(from_person, to_people, cc_people, msgdate, subj)] + message_to_chunks(payload, encrypted) else debug "no body for message/rfc822 enclosure; skipping" [] end elsif m.header.content_type && m.header.content_type.downcase == "application/pgp" && m.body ## apparently some versions of Thunderbird generate encryped email that ## does not follow RFC3156, e.g. messages with X-Enigmail-Version: 0.95.0 ## they have no MIME multipart and just set the body content type to ## application/pgp. this handles that. ## ## TODO 1: unduplicate code between here and ## multipart_encrypted_to_chunks ## TODO 2: this only tries to decrypt. it cannot handle inline PGP notice, sig, decryptedm = CryptoManager.decrypt m.body if decryptedm # managed to decrypt children = message_to_chunks decryptedm, true [notice, sig].compact + children else ## try inline pgp signed chunks = inline_gpg_to_chunks m.body, $encoding, (m.charset || $encoding) if chunks chunks else [notice] end end else filename = ## first, paw through the headers looking for a filename. ## RFC 2183 (Content-Disposition) specifies that disposition-parms are ## separated by ";". So, we match everything up to " and ; (if present). if m.header["Content-Disposition"] && m.header["Content-Disposition"] =~ /filename="?(.*?[^\\])("|;|\z)/m $1 elsif m.header["Content-Type"] && m.header["Content-Type"] =~ /name="?(.*?[^\\])("|;|\z)/im $1 ## haven't found one, but it's a non-text message. fake ## it. ## ## TODO: make this less lame. elsif m.header["Content-Type"] && m.header["Content-Type"] !~ /^text\/plain/i extension = case m.header["Content-Type"] when /text\/html/ then "html" when /image\/(.*)/ then $1 end ["sup-attachment-#{Time.now.to_i}-#{rand 10000}", extension].join(".") end ## if there's a filename, we'll treat it as an attachment. if filename ## filename could be 2047 encoded filename = Rfc2047.decode_to $encoding, filename # add this to the attachments list if its not a generated html # attachment (should we allow images with generated names?). # Lowercase the filename because searches are easier that way @attachments.push filename.downcase unless filename =~ /^sup-attachment-/ add_label :attachment unless filename =~ /^sup-attachment-/ content_type = (m.header.content_type || "application/unknown").downcase # sometimes RubyMail gives us nil [Chunk::Attachment.new(content_type, filename, m, sibling_types)] ## otherwise, it's body text else ## Decode the body, charset conversion will follow either in ## inline_gpg_to_chunks (for inline GPG signed messages) or ## a few lines below (messages without inline GPG) body = m.body ? m.decode : "" ## Check for inline-PGP chunks = inline_gpg_to_chunks body, $encoding, (m.charset || $encoding) return chunks if chunks if m.body ## if there's no charset, use the current encoding as the charset. ## this ensures that the body is normalized to avoid non-displayable ## characters body = m.decode.transcode($encoding, m.charset) else body = "" end text_to_chunks(body.normalize_whitespace.split("\n"), encrypted) end end end ## looks for gpg signed (but not encrypted) inline messages inside the ## message body (there is no extra header for inline GPG) or for encrypted ## (and possible signed) inline GPG messages def inline_gpg_to_chunks body, encoding_to, encoding_from lines = body.split("\n") # First case: Message is enclosed between # # -----BEGIN PGP SIGNED MESSAGE----- # and # -----END PGP SIGNED MESSAGE----- # # In some cases, END PGP SIGNED MESSAGE doesn't appear # (and may leave strange -----BEGIN PGP SIGNATURE----- ?) gpg = lines.between(GPG_SIGNED_START, GPG_SIGNED_END) # between does not check if GPG_END actually exists # Reference: http://permalink.gmane.org/gmane.mail.sup.devel/641 if !gpg.empty? msg = RMail::Message.new msg.body = gpg.join("\n") body = body.transcode(encoding_to, encoding_from) lines = body.split("\n") sig = lines.between(GPG_SIGNED_START, GPG_SIG_START) startidx = lines.index(GPG_SIGNED_START) endidx = lines.index(GPG_SIG_END) before = startidx != 0 ? lines[0 .. startidx-1] : [] after = endidx ? lines[endidx+1 .. lines.size] : [] # sig contains BEGIN PGP SIGNED MESSAGE and END PGP SIGNATURE, so # we ditch them. sig may also contain the hash used by PGP (with a # newline), so we also skip them sig_start = sig[1].match(/^Hash:/) ? 3 : 1 sig_end = sig.size-2 payload = RMail::Message.new payload.body = sig[sig_start, sig_end].join("\n") return [text_to_chunks(before, false), CryptoManager.verify(nil, msg, false), message_to_chunks(payload), text_to_chunks(after, false)].flatten.compact end # Second case: Message is encrypted gpg = lines.between(GPG_START, GPG_END) # between does not check if GPG_END actually exists if !gpg.empty? && !lines.index(GPG_END).nil? msg = RMail::Message.new msg.body = gpg.join("\n") startidx = lines.index(GPG_START) before = startidx != 0 ? lines[0 .. startidx-1] : [] after = lines[lines.index(GPG_END)+1 .. lines.size] notice, sig, decryptedm = CryptoManager.decrypt msg, true chunks = if decryptedm # managed to decrypt children = message_to_chunks(decryptedm, true) [notice, sig].compact + children else [notice] end return [text_to_chunks(before, false), chunks, text_to_chunks(after, false)].flatten.compact end end ## parse the lines of text into chunk objects. the heuristics here ## need tweaking in some nice manner. TODO: move these heuristics ## into the classes themselves. def text_to_chunks lines, encrypted state = :text # one of :text, :quote, or :sig chunks = [] chunk_lines = [] nextline_index = -1 lines.each_with_index do |line, i| if i >= nextline_index # look for next nonblank line only when needed to avoid O(n²) # behavior on sequences of blank lines if nextline_index = lines[(i+1)..-1].index { |l| l !~ /^\s*$/ } # skip blank lines nextline_index += i + 1 nextline = lines[nextline_index] else nextline_index = lines.length nextline = nil end end case state when :text newstate = nil ## the following /:$/ followed by /\w/ is an attempt to detect the ## start of a quote. this is split into two regexen because the ## original regex /\w.*:$/ had very poor behavior on long lines ## like ":a:a:a:a:a" that occurred in certain emails. if line =~ QUOTE_PATTERN || (line =~ /:$/ && line =~ /\w/ && nextline =~ QUOTE_PATTERN) newstate = :quote elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE && !lines[(i+1)..-1].index { |l| l =~ /^-- $/ } newstate = :sig elsif line =~ BLOCK_QUOTE_PATTERN && nextline !~ QUOTE_PATTERN newstate = :block_quote end if newstate chunks << Chunk::Text.new(chunk_lines) unless chunk_lines.empty? chunk_lines = [line] state = newstate else chunk_lines << line end when :quote newstate = nil if line =~ QUOTE_PATTERN || (line =~ /^\s*$/ && nextline =~ QUOTE_PATTERN) chunk_lines << line elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE newstate = :sig else newstate = :text end if newstate if chunk_lines.empty? # nothing else chunks << Chunk::Quote.new(chunk_lines) end chunk_lines = [line] state = newstate end when :block_quote, :sig chunk_lines << line end if !@have_snippet && state == :text && (@snippet.nil? || @snippet.length < SNIPPET_LEN) && line !~ /[=\*#_-]{3,}/ && line !~ /^\s*$/ @snippet ||= "" @snippet += " " unless @snippet.empty? @snippet += line.gsub(/^\s+/, "").gsub(/[\r\n]/, "").gsub(/\s+/, " ") oldlen = @snippet.length @snippet = @snippet[0 ... SNIPPET_LEN].chomp @snippet += "..." if @snippet.length < oldlen @dirty = true unless encrypted && $config[:discard_snippets_from_encrypted_messages] @snippet_contains_encrypted_content = true if encrypted end end ## final object case state when :quote, :block_quote chunks << Chunk::Quote.new(chunk_lines) unless chunk_lines.empty? when :text chunks << Chunk::Text.new(chunk_lines) unless chunk_lines.empty? when :sig chunks << Chunk::Signature.new(chunk_lines) unless chunk_lines.empty? end chunks end def warn_with_location msg warn msg warn "Message is in #{location.source.uri} at #{location.info}" end end class Location attr_reader :source attr_reader :info def initialize source, info @source = source @info = info end def raw_header source.raw_header info end def raw_message source.raw_message info end def sync_back labels, message synced = false return synced unless sync_back_enabled? and valid? source.synchronize do new_info = source.sync_back(@info, labels) if new_info @info = new_info Index.sync_message message, true synced = true end end synced end def sync_back_enabled? source.respond_to? :sync_back and $config[:sync_back_to_maildir] and source.sync_back_enabled? end ## much faster than raw_message def each_raw_message_line &b source.each_raw_message_line info, &b end def parsed_message source.load_message info end def valid? source.valid? info end def labels? source.labels? info end def == o o.source.id == source.id and o.info == info end def hash [source.id, info].hash end end end sup-1.1/lib/sup/sent.rb0000644000004100000410000000233214246427237015100 0ustar www-datawww-datamodule Redwood class SentManager include Redwood::Singleton attr_reader :source, :source_uri def initialize source_uri @source = nil @source_uri = source_uri end def source_id; @source.id; end def source= s raise FatalSourceError.new("Configured sent_source [#{s.uri}] can't store mail. Correct your configuration.") unless s.respond_to? :store_message @source_uri = s.uri @source = s end def default_source @source = SentLoader.new @source_uri = @source.uri @source end def write_sent_message date, from_email, &block ::Thread.new do debug "store the sent message (locking sent source..)" @source.synchronize do @source.store_message date, from_email, &block end PollManager.poll_from @source end end end class SentLoader < MBox yaml_properties def initialize @filename = Redwood::SENT_FN File.open(@filename, "w") { } unless File.exist? @filename super "mbox://" + @filename, true, $config[:archive_sent] end def file_path; @filename end def to_s; 'sup://sent'; end def uri; 'sup://sent' end def id; 9998; end def labels; [:inbox, :sent]; end def default_labels; []; end def read?; true; end end end sup-1.1/lib/sup/rfc2047.rb0000644000004100000410000000464614246427237015230 0ustar www-datawww-data## from: http://blade.nagaokaut.ac.jp/cgi-bin/scat.rb/ruby/ruby-talk/101949 # $Id: rfc2047.rb,v 1.4 2003/04/18 20:55:56 sam Exp $ # MODIFIED slightly by William Morgan # # An implementation of RFC 2047 decoding. # # This module depends on the iconv library by Nobuyoshi Nakada, which I've # heard may be distributed as a standard part of Ruby 1.8. Many thanks to him # for helping with building and using iconv. # # Thanks to "Josef 'Jupp' Schugt" for pointing out an error with # stateful character sets. # # Copyright (c) Sam Roberts 2004 # # This file is distributed under the same terms as Ruby. module Rfc2047 WORD = %r{=\?([!\#$%&'*+-/0-9A-Z\\^\`a-z{|}~]+)\?([BbQq])\?([!->@-~ ]+)\?=} # :nodoc: 'stupid ruby-mode WORDSEQ = %r{(#{WORD.source})\s+(?=#{WORD.source})} def Rfc2047.is_encoded? s; s =~ WORD end # Decodes a string, +from+, containing RFC 2047 encoded words into a target # character set, +target+. See iconv_open(3) for information on the # supported target encodings. If one of the encoded words cannot be # converted to the target encoding, it is left in its encoded form. def Rfc2047.decode_to(target, from) from = from.gsub(WORDSEQ, '\1') from.gsub(WORD) do |word| charset, encoding, text = $1, $2, $3 # B64 or QP decode, as necessary: case encoding when 'b', 'B' ## Padding is optional in RFC 2047 words. Add some extra padding ## before decoding the base64, otherwise on Ruby 2.0 the final byte ## might be discarded. text = (text + '===').unpack('m*')[0] when 'q', 'Q' # RFC 2047 has a variant of quoted printable where a ' ' character # can be represented as an '_', rather than =32, so convert # any of these that we find before doing the QP decoding. text = text.tr("_", " ") text = text.unpack('M*')[0] # Don't need an else, because no other values can be matched in a # WORD. end # Handle UTF-7 specially because Ruby doesn't actually support it as # a normal character encoding. if charset == 'UTF-7' begin next text.decode_utf7.encode(target) rescue ArgumentError, EncodingError next word end end begin text.force_encoding(charset).encode(target) rescue ArgumentError, EncodingError word end end end end sup-1.1/lib/sup/util/0000755000004100000410000000000014246427237014557 5ustar www-datawww-datasup-1.1/lib/sup/util/axe.rb0000644000004100000410000000053114246427237015660 0ustar www-datawww-datarequire 'highline' @cli = HighLine.new def axe q, default=nil question = if default && !default.empty? "#{q} (enter for \"#{default}\"): " else "#{q}: " end ans = @cli.ask question ans.empty? ? default : ans.to_s end def axe_yes q, default="n" axe(q, default) =~ /^y|yes$/i end sup-1.1/lib/sup/util/uri.rb0000644000004100000410000000043114246427237015701 0ustar www-datawww-datarequire "uri" require "sup/util/path" module Redwood module Util module Uri def self.build(components) components = components.dup components[:path] = Path.expand(components[:path]) ::URI::Generic.build(components) end end end end sup-1.1/lib/sup/util/query.rb0000644000004100000410000000057214246427237016255 0ustar www-datawww-datamodule Redwood module Util module Query class QueryDescriptionError < ArgumentError; end def self.describe(query, fallback = nil) d = query.description.force_encoding("UTF-8") unless d.valid_encoding? raise QueryDescriptionError.new(d) unless fallback d = fallback end return d end end end end sup-1.1/lib/sup/util/ncurses.rb0000644000004100000410000002103114246427237016563 0ustar www-datawww-datarequire 'ncursesw' require 'sup/util' if defined? Ncurses module Ncurses ## Helper class for storing keycodes ## and multibyte characters. class CharCode < String ## Status code allows us to detect ## printable characters and control codes. attr_reader :status ## Reads character from user input. def self.nonblocking_getwch # If we get input while we're shelled, we'll ignore it for the # moment and use Ncurses.sync to wait until the shell_out is done. begin s, c = Redwood::BufferManager.shelled? ? Ncurses.sync { nil } : Ncurses.get_wch break if s != Ncurses::ERR end until IO.select([$stdin], nil, nil, 2) [s, c] end ## Returns empty singleton. def self.empty Empty.instance end ## Creates new instance of CharCode ## that keeps a given keycode. def self.keycode(c) generate c, Ncurses::KEY_CODE_YES end ## Creates new instance of CharCode ## that keeps a printable character. def self.character(c) generate c, Ncurses::OK end ## Generates new object like new ## but for empty or erroneous objects ## it returns empty singleton. def self.generate(c = nil, status = Ncurses::OK) if status == Ncurses::ERR || c.nil? || c === Ncurses::ERR empty else new(c, status) end end ## Gets character from input. ## Pretends ctrl-c's are ctrl-g's. def self.get handle_interrupt=true begin status, code = nonblocking_getwch generate code, status rescue Interrupt => e raise e unless handle_interrupt keycode Ncurses::KEY_CANCEL end end ## Enables dumb mode for any new instance. def self.dumb! @dumb = true end ## Asks if dumb mode was set def self.dumb? defined?(@dumb) && @dumb end def initialize(c = "", status = Ncurses::OK) @status = status c = "" if c.nil? return super("") if status == Ncurses::ERR c = enc_char(c) if c.is_a?(Integer) super c.length > 1 ? c[0,1] : c end ## Proxy method for String's replace def replace(c) return self if c.object_id == object_id if c.is_a?(self.class) @status = c.status super(c) else @status = Ncurses::OK c = "" if c.nil? c = enc_char(c) if c.is_a?(Integer) super c.length > 1 ? c[0,1] : c end end def to_character ; character? ? self : "<#{code}>" end ## Returns character or code as a string def to_keycode ; keycode? ? code : Ncurses::ERR end ## Returns keycode or ERR if it's not a keycode def to_sequence ; bytes.to_a end ## Returns unpacked sequence of bytes for a character def code ; ord end ## Returns decimal representation of a character def is_keycode?(c) ; keycode? && code == c end ## Tests if keycode matches def is_character?(c); character? && self == c end ## Tests if character matches def try_keycode ; keycode? ? code : nil end ## Returns dec. code if keycode, nil otherwise def try_character ; character? ? self : nil end ## Returns character if character, nil otherwise def keycode ; try_keycode end ## Alias for try_keycode def character ; try_character end ## Alias for try_character def character? ; dumb? || @status == Ncurses::OK end ## Returns true if character def character! ; @status = Ncurses::OK ; self end ## Sets character flag def keycode? ; dumb? || @status == Ncurses::KEY_CODE_YES end ## Returns true if keycode def keycode! ; @status = Ncurses::KEY_CODE_YES ; self end ## Sets keycode flag def keycode=(c) ; replace(c); keycode! ; self end ## Sets keycode def present? ; not empty? end ## Proxy method def printable? ; character? end ## Alias for character? def dumb? ; self.class.dumb? end ## True if we cannot distinguish keycodes from characters # Empty singleton that # keeps GC from going crazy. class Empty < CharCode include Redwood::Singleton ## Wrap methods that may change us ## and generate new object instead. [ :"[]=", :"<<", :replace, :insert, :prepend, :append, :concat, :force_encoding, :setbyte ]. select{ |m| public_method_defined?(m) }. concat(public_instance_methods.grep(/!\z/)). each do |m| class_eval <<-EVAL def #{m}(*args) CharCode.new.#{m}(*args) end EVAL end ## proxy with class-level instance variable delegation def self.dumb? superclass.dumb? or !!@dumb end def self.empty instance end def initialize super("", Ncurses::ERR) end def empty? ; true end ## always true def present? ; false end ## always false def clear ; self end ## always self self end.init # CharCode::Empty private ## Tries to make external character right. def enc_char(c) begin character = c.chr($encoding) rescue RangeError, ArgumentError begin character = [c].pack('U') rescue RangeError begin character = c.chr rescue begin character = [c].pack('C') rescue character = "" @status = Ncurses::ERR end end end character.fix_encoding! end end end # class CharCode def rows lame, lamer = [], [] stdscr.getmaxyx lame, lamer lame.first end def cols lame, lamer = [], [] stdscr.getmaxyx lame, lamer lamer.first end def curx lame, lamer = [], [] stdscr.getyx lame, lamer lamer.first end ## Create replacement wrapper for form_driver_w (), which is not (yet) a standard ## function in ncurses. Some systems (Mac OS X) does not have a working ## form_driver that accepts wide chars. We are just falling back to form_driver, expect problems. def prepare_form_driver if not defined? Form.form_driver_w warn "Your Ncursesw does not have a form_driver_w function (wide char aware), " \ "non-ASCII chars may not work on your system." Form.module_eval <<-FRM_DRV, __FILE__, __LINE__ + 1 def form_driver_w form, status, c form_driver form, c end module_function :form_driver_w module DriverHelpers def form_driver c if !c.dumb? && c.printable? c.each_byte do |code| Ncurses::Form.form_driver @form, code end else Ncurses::Form.form_driver @form, c.code end end end FRM_DRV end # if not defined? Form.form_driver_w if not defined? Ncurses.get_wch warn "Your Ncursesw does not have a get_wch function (wide char aware), " \ "non-ASCII chars may not work on your system." Ncurses.module_eval <<-GET_WCH, __FILE__, __LINE__ + 1 def get_wch c = getch c == Ncurses::ERR ? [c, 0] : [Ncurses::OK, c] end module_function :get_wch GET_WCH CharCode.dumb! end # if not defined? Ncurses.get_wch end def mutex; @mutex ||= Mutex.new; end def sync &b; mutex.synchronize(&b); end module_function :rows, :cols, :curx, :mutex, :sync, :prepare_form_driver remove_const :KEY_ENTER remove_const :KEY_CANCEL KEY_ENTER = 10 KEY_CANCEL = 7 # ctrl-g KEY_TAB = 9 module Form ## This module contains helpers that ease ## using form_driver_ methods when @form is present. module DriverHelpers private ## Ncurses::Form.form_driver_w wrapper for keycodes and control characters. def form_driver_key c form_driver CharCode.keycode(c) end ## Ncurses::Form.form_driver_w wrapper for printable characters. def form_driver_char c form_driver CharCode.character(c) #c.is_a?(Integer) ? c : c.ord end ## Ncurses::Form.form_driver_w wrapper for charcodes. def form_driver c Ncurses::Form.form_driver_w @form, c.status, c.code end end # module DriverHelpers end # module Form end # module Ncurses end # if defined? Ncurses sup-1.1/lib/sup/util/locale_fiddler.rb0000644000004100000410000000137114246427237020036 0ustar www-datawww-data## the following magic enables wide characters when used with a ruby ## ncurses.so that's been compiled against libncursesw. (note the w.) why ## this works, i have no idea. much like pretty much every aspect of ## dealing with curses. cargo cult programming at its best. require 'fiddle' require 'fiddle/import' module LocaleFiddler extend Fiddle::Importer SETLOCALE_LIB = case RbConfig::CONFIG['arch'] when /darwin/; "libc.dylib" when /cygwin/; "cygwin1.dll" when /freebsd/; "libc.so.7" else; "libc.so.6" end dlload SETLOCALE_LIB extern "char *setlocale(int, char const *)" def setlocale(type, string) LocaleFiddler.setlocale(type, string) end end sup-1.1/lib/sup/util/path.rb0000644000004100000410000000020614246427237016036 0ustar www-datawww-datamodule Redwood module Util module Path def self.expand(path) ::File.expand_path(path) end end end end sup-1.1/lib/sup/person.rb0000644000004100000410000000616014246427237015440 0ustar www-datawww-datamodule Redwood class Person attr_accessor :name, :email def initialize name, email raise ArgumentError, "email can't be nil" unless email email.fix_encoding! @name = if name name.fix_encoding! name = name.strip.gsub(/\s+/, " ") name =~ /^(['"]\s*)(.*?)(\s*["'])$/ ? $2 : name name.gsub('\\\\', '\\') end @email = email.strip.gsub(/\s+/, " ") end def to_s if @name "#@name <#@email>" else @email end end # def == o; o && o.email == email; end # alias :eql? :== def shortname case @name when /\S+, (\S+)/ $1 when /(\S+) \S+/ $1 when nil @email else @name end end def mediumname; @name || @email; end def longname to_s end def full_address Person.full_address @name, @email end ## when sorting addresses, sort by this def sort_by_me case @name when /^(\S+), \S+/ $1 when /^\S+ \S+ (\S+)/ $1 when /^\S+ (\S+)/ $1 when nil @email else @name end.downcase end def eql? o; email.eql? o.email end def hash; email.hash end ## see comments in self.from_address def indexable_content [name, email, email.split(/@/).first].join(" ") end class << self def full_address name, email if name && email if name =~ /[",@]/ "#{name.inspect} <#{email}>" # escape quotes else "#{name} <#{email}>" end else email end end ## return "canonical" person using contact manager or create one if ## not found or contact manager not available def from_name_and_email name, email ContactManager.instantiated? && ContactManager.person_for(email) || Person.new(name, email) end def from_address s return nil if s.nil? ## try and parse an email address and name name, email = case s when /(.+?) ((\S+?)@\S+) \3/ ## ok, this first match cause is insane, but bear with me. email ## addresses are stored in the to/from/etc fields of the index in a ## weird format: "name address first-part-of-address", i.e. spaces ## separating those three bits, and no <>'s. this is the output of ## #indexable_content. here, we reverse-engineer that format to extract ## a valid address. ## ## we store things this way to allow searches on a to/from/etc field to ## match any of those parts. a more robust solution would be to store a ## separate, non-indexed field with the proper headers. but this way we ## save precious bits, and it's backwards-compatible with older indexes. [$1, $2] when /["'](.*?)["'] <(.*?)>/, /([^,]+) <(.*?)>/ a, b = $1, $2 [a.gsub('\"', '"'), b] when /<((\S+?)@\S+?)>/ [$2, $1] when /((\S+?)@\S+)/ [$2, $1] else [nil, s] end from_name_and_email name, email end def from_address_list ss return [] if ss.nil? ss.dup.split_on_commas.map { |s| self.from_address s } end end end end sup-1.1/lib/sup/index.rb0000644000004100000410000007010314246427237015237 0ustar www-datawww-dataENV["XAPIAN_FLUSH_THRESHOLD"] = "1000" ENV["XAPIAN_CJK_NGRAM"] = "1" require 'xapian' require 'set' require 'fileutils' require 'monitor' require 'chronic' require "sup/util/query" require "sup/interactive_lock" require "sup/hook" require "sup/logger/singleton" if ([Xapian.major_version, Xapian.minor_version, Xapian.revision] <=> [1,2,15]) < 0 fail <<-EOF \n Xapian version 1.2.15 or higher required. If you have xapian-full-alaveteli installed, Please remove it by running `gem uninstall xapian-full-alaveteli` since it's been replaced by the xapian-ruby gem. EOF end module Redwood # This index implementation uses Xapian for searching and storage. It # tends to be slightly faster than Ferret for indexing and significantly faster # for searching due to precomputing thread membership. class Index include InteractiveLock INDEX_VERSION = '4' ## dates are converted to integers for xapian, and are used for document ids, ## so we must ensure they're reasonably valid. this typically only affect ## spam. MIN_DATE = Time.at 0 MAX_DATE = Time.at(2**31-1) HookManager.register "custom-search", < 0, :max_age => nil @sync_worker = nil @sync_queue = Queue.new @index_mutex = Monitor.new end def lockfile; File.join @dir, "lock" end def lock debug "locking #{lockfile}..." begin @lock.lock rescue Lockfile::MaxTriesLockError raise LockError, @lock.lockinfo_on_disk end end def start_lock_update_thread @lock_update_thread = Redwood::reporting_thread("lock update") do while true sleep 30 @lock.touch_yourself end end end def stop_lock_update_thread @lock_update_thread.kill if @lock_update_thread @lock_update_thread = nil end def unlock if @lock && @lock.locked? debug "unlocking #{lockfile}..." @lock.unlock end end def load failsafe=false SourceManager.load_sources load_index failsafe end def save debug "saving index and sources..." FileUtils.mkdir_p @dir unless File.exist? @dir SourceManager.save_sources save_index end def get_xapian @xapian end def load_index failsafe=false path = File.join(@dir, 'xapian') if File.exist? path @xapian = Xapian::WritableDatabase.new(path, Xapian::DB_OPEN) db_version = @xapian.get_metadata 'version' db_version = '0' if db_version.empty? if false info "Upgrading index format #{db_version} to #{INDEX_VERSION}" @xapian.set_metadata 'version', INDEX_VERSION elsif db_version != INDEX_VERSION fail "This Sup version expects a v#{INDEX_VERSION} index, but you have an existing v#{db_version} index. Please run sup-dump to save your labels, move #{path} out of the way, and run sup-sync --restore." end else @xapian = Xapian::WritableDatabase.new(path, Xapian::DB_CREATE) @xapian.set_metadata 'version', INDEX_VERSION @xapian.set_metadata 'rescue-version', '0' end @enquire = Xapian::Enquire.new @xapian @enquire.weighting_scheme = Xapian::BoolWeight.new @enquire.docid_order = Xapian::Enquire::ASCENDING end def add_message m; sync_message m, true end def update_message m; sync_message m, true end def update_message_state m; sync_message m[0], false, m[1] end def save_index info "Flushing Xapian updates to disk. This may take a while..." @xapian.flush end def contains_id? id synchronize { find_docid(id) && true } end def contains? m; contains_id? m.id end def size synchronize { @xapian.doccount } end def empty?; size == 0 end ## Yields a message-id and message-building lambda for each ## message that matches the given query, in descending date order. ## You should probably not call this on a block that doesn't break ## rather quickly because the results can be very large. def each_id_by_date query={} each_id(query) { |id| yield id, lambda { build_message id } } end ## Return the number of matches for query in the index def num_results_for query={} xapian_query = build_xapian_query query matchset = run_query xapian_query, 0, 0, 100 matchset.matches_estimated end ## check if a message is part of a killed thread ## (warning: duplicates code below) ## NOTE: We can be more efficient if we assume every ## killed message that hasn't been initially added ## to the indexi s this way def message_joining_killed? m return false unless doc = find_doc(m.id) queue = doc.value(THREAD_VALUENO).split(',') seen_threads = Set.new seen_messages = Set.new [m.id] while not queue.empty? thread_id = queue.pop next if seen_threads.member? thread_id return true if thread_killed?(thread_id) seen_threads << thread_id docs = term_docids(mkterm(:thread, thread_id)).map { |x| @xapian.document x } docs.each do |doc| msgid = doc.value MSGID_VALUENO next if seen_messages.member? msgid seen_messages << msgid queue.concat doc.value(THREAD_VALUENO).split(',') end end false end ## yield all messages in the thread containing 'm' by repeatedly ## querying the index. yields pairs of message ids and ## message-building lambdas, so that building an unwanted message ## can be skipped in the block if desired. ## ## only two options, :limit and :skip_killed. if :skip_killed is ## true, stops loading any thread if a message with a :killed flag ## is found. def each_message_in_thread_for m, opts={} # TODO thread by subject return unless doc = find_doc(m.id) queue = doc.value(THREAD_VALUENO).split(',') msgids = [m.id] seen_threads = Set.new seen_messages = Set.new [m.id] while not queue.empty? thread_id = queue.pop next if seen_threads.member? thread_id return false if opts[:skip_killed] && thread_killed?(thread_id) seen_threads << thread_id docs = term_docids(mkterm(:thread, thread_id)).map { |x| @xapian.document x } docs.each do |doc| msgid = doc.value MSGID_VALUENO next if seen_messages.member? msgid msgids << msgid seen_messages << msgid queue.concat doc.value(THREAD_VALUENO).split(',') end end msgids.each { |id| yield id, lambda { build_message id } } true end ## Load message with the given message-id from the index def build_message id entry = synchronize { get_entry id } return unless entry locations = entry[:locations].map do |source_id,source_info| source = SourceManager[source_id] raise "invalid source #{source_id}" unless source Location.new source, source_info end m = Message.new :locations => locations, :labels => entry[:labels], :snippet => entry[:snippet] # Try to find person from contacts before falling back to # generating it from the address. mk_person = lambda { |x| Person.from_name_and_email(*x.reverse!) } entry[:from] = mk_person[entry[:from]] entry[:to].map!(&mk_person) entry[:cc].map!(&mk_person) entry[:bcc].map!(&mk_person) m.load_from_index! entry m end ## Delete message with the given message-id from the index def delete id synchronize { @xapian.delete_document mkterm(:msgid, id) } end ## Given an array of email addresses, return an array of Person objects that ## have sent mail to or received mail from any of the given addresses. def load_contacts email_addresses, opts={} contacts = Set.new num = opts[:num] || 20 each_id_by_date :participants => email_addresses do |id,b| break if contacts.size >= num m = b.call ([m.from]+m.to+m.cc+m.bcc).compact.each { |p| contacts << [p.name, p.email] } end contacts.to_a.compact[0...num].map { |n,e| Person.from_name_and_email n, e } end ## Yield each message-id matching query EACH_ID_PAGE = 100 def each_id query={}, ignore_neg_terms = true offset = 0 page = EACH_ID_PAGE xapian_query = build_xapian_query query, ignore_neg_terms while true ids = run_query_ids xapian_query, offset, (offset+page) ids.each { |id| yield id } break if ids.size < page offset += page end end ## Yield each message matching query ## The ignore_neg_terms parameter is used to display result even if ## it contains "forbidden" labels such as :deleted, it is used in ## Poll#poll_from when we need to get the location of a message that ## may contain these labels def each_message query={}, ignore_neg_terms = true, &b each_id query, ignore_neg_terms do |id| yield build_message(id) end end # Search messages. Returns an Enumerator. def find_messages query_expr enum_for :each_message, parse_query(query_expr) end # wrap all future changes inside a transaction so they're done atomically def begin_transaction synchronize { @xapian.begin_transaction } end # complete the transaction and write all previous changes to disk def commit_transaction synchronize { @xapian.commit_transaction } end # abort the transaction and revert all changes made since begin_transaction def cancel_transaction synchronize { @xapian.cancel_transaction } end ## xapian-compact takes too long, so this is a no-op ## until we think of something better def optimize end ## Return the id source of the source the message with the given message-id ## was synced from def source_for_id id synchronize { get_entry(id)[:source_id] } end ## Yields each term in the index that starts with prefix def each_prefixed_term prefix term = @xapian._dangerous_allterms_begin prefix lastTerm = @xapian._dangerous_allterms_end prefix until term.equals lastTerm yield term.term term.next end nil end ## Yields (in lexicographical order) the source infos of all locations from ## the given source with the given source_info prefix def each_source_info source_id, prefix='', &b p = mkterm :location, source_id, prefix each_prefixed_term p do |x| yield prefix + x[p.length..-1] end end class ParseError < StandardError; end # Stemmed NORMAL_PREFIX = { 'subject' => {:prefix => 'S', :exclusive => false}, 'body' => {:prefix => 'B', :exclusive => false}, 'from_name' => {:prefix => 'FN', :exclusive => false}, 'to_name' => {:prefix => 'TN', :exclusive => false}, 'name' => {:prefix => %w(FN TN), :exclusive => false}, 'attachment' => {:prefix => 'A', :exclusive => false}, 'email_text' => {:prefix => 'E', :exclusive => false}, '' => {:prefix => %w(S B FN TN A E), :exclusive => false}, } # Unstemmed BOOLEAN_PREFIX = { 'type' => {:prefix => 'K', :exclusive => true}, 'from_email' => {:prefix => 'FE', :exclusive => false}, 'to_email' => {:prefix => 'TE', :exclusive => false}, 'email' => {:prefix => %w(FE TE), :exclusive => false}, 'date' => {:prefix => 'D', :exclusive => true}, 'label' => {:prefix => 'L', :exclusive => false}, 'source_id' => {:prefix => 'I', :exclusive => true}, 'attachment_extension' => {:prefix => 'O', :exclusive => false}, 'msgid' => {:prefix => 'Q', :exclusive => true}, 'id' => {:prefix => 'Q', :exclusive => true}, 'thread' => {:prefix => 'H', :exclusive => false}, 'ref' => {:prefix => 'R', :exclusive => false}, 'location' => {:prefix => 'J', :exclusive => false}, } PREFIX = NORMAL_PREFIX.merge BOOLEAN_PREFIX COMPL_OPERATORS = %w[AND OR NOT] COMPL_PREFIXES = ( %w[ from to is has label filename filetypem before on in during after limit ] + NORMAL_PREFIX.keys + BOOLEAN_PREFIX.keys ).map{|p|"#{p}:"} + COMPL_OPERATORS ## parse a query string from the user. returns a query object ## that can be passed to any index method with a 'query' ## argument. ## ## raises a ParseError if something went wrong. def parse_query s query = {} subs = HookManager.run("custom-search", :subs => s) || s begin subs = SearchManager.expand subs rescue SearchManager::ExpansionError => e raise ParseError, e.message end subs = subs.gsub(/\b(to|from):(\S+)\b/) do field, value = $1, $2 email_field, name_field = %w(email name).map { |x| "#{field}_#{x}" } if(p = ContactManager.contact_for(value)) "#{email_field}:#{p.email}" elsif value == "me" '(' + AccountManager.user_emails.map { |e| "#{email_field}:#{e}" }.join(' OR ') + ')' else "(#{email_field}:#{value} OR #{name_field}:#{value})" end end ## gmail style "is" operator subs = subs.gsub(/\b(is|has):(\S+)\b/) do _field, label = $1, $2 case label when "read" "-label:unread" when "spam" query[:load_spam] = true "label:spam" when "deleted" query[:load_deleted] = true "label:deleted" else "label:#{$2}" end end ## labels are stored lower-case in the index subs = subs.gsub(/\blabel:(\S+)\b/) do label = $1 "label:#{label.downcase}" end ## if we see a label:deleted or a label:spam term anywhere in the query ## string, we set the extra load_spam or load_deleted options to true. ## bizarre? well, because the query allows arbitrary parenthesized boolean ## expressions, without fully parsing the query, we can't tell whether ## the user is explicitly directing us to search spam messages or not. ## e.g. if the string is -(-(-(-(-label:spam)))), does the user want to ## search spam messages or not? ## ## so, we rely on the fact that turning these extra options ON turns OFF ## the adding of "-label:deleted" or "-label:spam" terms at the very ## final stage of query processing. if the user wants to search spam ## messages, not adding that is the right thing; if he doesn't want to ## search spam messages, then not adding it won't have any effect. query[:load_spam] = true if subs =~ /\blabel:spam\b/ query[:load_deleted] = true if subs =~ /\blabel:deleted\b/ query[:load_killed] = true if subs =~ /\blabel:killed\b/ ## gmail style attachments "filename" and "filetype" searches subs = subs.gsub(/\b(filename|filetype):(\((.+?)\)\B|(\S+)\b)/) do field, name = $1, ($3 || $4) case field when "filename" debug "filename: translated #{field}:#{name} to attachment:\"#{name.downcase}\"" "attachment:\"#{name.downcase}\"" when "filetype" debug "filetype: translated #{field}:#{name} to attachment_extension:#{name.downcase}" "attachment_extension:#{name.downcase}" end end lastdate = 2<<32 - 1 firstdate = 0 subs = subs.gsub(/\b(before|on|in|during|after):(\((.+?)\)\B|(\S+)\b)/) do field, datestr = $1, ($3 || $4) realdate = Chronic.parse datestr, :guess => false, :context => :past if realdate case field when "after" debug "chronic: translated #{field}:#{datestr} to #{realdate.end}" "date:#{realdate.end.to_i}..#{lastdate}" when "before" debug "chronic: translated #{field}:#{datestr} to #{realdate.begin}" "date:#{firstdate}..#{realdate.end.to_i}" else debug "chronic: translated #{field}:#{datestr} to #{realdate}" "date:#{realdate.begin.to_i}..#{realdate.end.to_i}" end else raise ParseError, "can't understand date #{datestr.inspect}" end end ## limit:42 restrict the search to 42 results subs = subs.gsub(/\blimit:(\S+)\b/) do lim = $1 if lim =~ /^\d+$/ query[:limit] = lim.to_i '' else raise ParseError, "non-numeric limit #{lim.inspect}" end end debug "translated query: #{subs.inspect}" qp = Xapian::QueryParser.new qp.database = @xapian qp.stemmer = Xapian::Stem.new($config[:stem_language]) qp.stemming_strategy = Xapian::QueryParser::STEM_SOME qp.default_op = Xapian::Query::OP_AND valuerangeprocessor = Xapian::NumberValueRangeProcessor.new(DATE_VALUENO, 'date:', true) qp.add_valuerangeprocessor(valuerangeprocessor) NORMAL_PREFIX.each { |k,info| info[:prefix].each { |v| qp.add_prefix k, v } } BOOLEAN_PREFIX.each { |k,info| info[:prefix].each { |v| qp.add_boolean_prefix k, v, info[:exclusive] } } begin xapian_query = qp.parse_query(subs, Xapian::QueryParser::FLAG_PHRASE | Xapian::QueryParser::FLAG_BOOLEAN | Xapian::QueryParser::FLAG_LOVEHATE | Xapian::QueryParser::FLAG_WILDCARD) rescue RuntimeError => e raise ParseError, "xapian query parser error: #{e}" end debug "parsed xapian query: #{Util::Query.describe(xapian_query, subs)}" if xapian_query.nil? or xapian_query.empty? raise ParseError, "couldn't parse \"#{s}\" as xapian query " \ "(special characters aren't indexed)" end query[:qobj] = xapian_query query[:text] = s query end def save_message m, sync_back = true if @sync_worker @sync_queue << [m, sync_back] else update_message_state [m, sync_back] end m.clear_dirty end def save_thread t, sync_back = true t.each_dirty_message do |m| save_message m, sync_back end end def start_sync_worker @sync_worker = Redwood::reporting_thread('index sync') { run_sync_worker } end def stop_sync_worker return unless worker = @sync_worker @sync_worker = nil @sync_queue << :die worker.join end def run_sync_worker while m = @sync_queue.deq return if m == :die update_message_state m # Necessary to keep Xapian calls from lagging the UI too much. sleep 0.03 end end private MSGID_VALUENO = 0 THREAD_VALUENO = 1 DATE_VALUENO = 2 MAX_TERM_LENGTH = 245 # Xapian can very efficiently sort in ascending docid order. Sup always wants # to sort by descending date, so this method maps between them. In order to # handle multiple messages per second, we use a logistic curve centered # around MIDDLE_DATE so that the slope (docid/s) is greatest in this time # period. A docid collision is not an error - the code will pick the next # smallest unused one. DOCID_SCALE = 2.0**32 TIME_SCALE = 2.0**27 MIDDLE_DATE = Time.gm(2011) def assign_docid m, truncated_date t = (truncated_date.to_i - MIDDLE_DATE.to_i).to_f docid = (DOCID_SCALE - DOCID_SCALE/(Math::E**(-(t/TIME_SCALE)) + 1)).to_i while docid > 0 and docid_exists? docid docid -= 1 end docid > 0 ? docid : nil end # XXX is there a better way? def docid_exists? docid begin @xapian.doclength docid true rescue RuntimeError #Xapian::DocNotFoundError raise unless $!.message =~ /DocNotFoundError/ false end end def term_docids term @xapian.postlist(term).map { |x| x.docid } end def find_docid id docids = term_docids(mkterm(:msgid,id)) fail unless docids.size <= 1 docids.first end def find_doc id return unless docid = find_docid(id) @xapian.document docid end def get_id docid return unless doc = @xapian.document(docid) doc.value MSGID_VALUENO end def get_entry id return unless doc = find_doc(id) doc.entry end def thread_killed? thread_id not run_query(Q.new(Q::OP_AND, mkterm(:thread, thread_id), mkterm(:label, :Killed)), 0, 1).empty? end def synchronize &b @index_mutex.synchronize(&b) end def run_query xapian_query, offset, limit, checkatleast=0 synchronize do @enquire.query = xapian_query @enquire.mset(offset, limit-offset, checkatleast) end end def run_query_ids xapian_query, offset, limit matchset = run_query xapian_query, offset, limit matchset.matches.map { |r| r.document.value MSGID_VALUENO } end Q = Xapian::Query def build_xapian_query opts, ignore_neg_terms = true labels = ([opts[:label]] + (opts[:labels] || [])).compact neglabels = [:spam, :deleted, :killed].reject { |l| (labels.include? l) || opts.member?("load_#{l}".intern) } pos_terms, neg_terms = [], [] pos_terms << mkterm(:type, 'mail') pos_terms.concat(labels.map { |l| mkterm(:label,l) }) pos_terms << opts[:qobj] if opts[:qobj] pos_terms << mkterm(:source_id, opts[:source_id]) if opts[:source_id] pos_terms << mkterm(:location, *opts[:location]) if opts[:location] if opts[:participants] participant_terms = opts[:participants].map { |p| [:from,:to].map { |d| mkterm(:email, d, (Redwood::Person === p) ? p.email : p) } }.flatten pos_terms << Q.new(Q::OP_OR, participant_terms) end neg_terms.concat(neglabels.map { |l| mkterm(:label,l) }) if ignore_neg_terms pos_query = Q.new(Q::OP_AND, pos_terms) neg_query = Q.new(Q::OP_OR, neg_terms) if neg_query.empty? pos_query else Q.new(Q::OP_AND_NOT, [pos_query, neg_query]) end end def sync_message m, overwrite, sync_back = true ## TODO: we should not save the message if the sync_back failed ## since it would overwrite the location field m.sync_back if sync_back doc = synchronize { find_doc(m.id) } existed = doc != nil doc ||= Xapian::Document.new do_index_static = overwrite || !existed old_entry = !do_index_static && doc.entry snippet = do_index_static ? m.snippet : old_entry[:snippet] entry = { :message_id => m.id, :locations => m.locations.map { |x| [x.source.id, x.info] }, :date => truncate_date(m.date), :snippet => snippet, :labels => m.labels.to_a, :from => [m.from.email, m.from.name], :to => m.to.map { |p| [p.email, p.name] }, :cc => m.cc.map { |p| [p.email, p.name] }, :bcc => m.bcc.map { |p| [p.email, p.name] }, :subject => m.subj, :refs => m.refs.to_a, :replytos => m.replytos.to_a, } if do_index_static doc.clear_terms doc.clear_values index_message_static m, doc, entry end index_message_locations doc, entry, old_entry index_message_threading doc, entry, old_entry index_message_labels doc, entry[:labels], (do_index_static ? [] : old_entry[:labels]) doc.entry = entry synchronize do unless docid = existed ? doc.docid : assign_docid(m, truncate_date(m.date)) # Could be triggered by spam warn "docid underflow, dropping #{m.id.inspect}" return end @xapian.replace_document docid, doc end m.labels.each { |l| LabelManager << l } true end ## Index content that can't be changed by the user def index_message_static m, doc, entry # Person names are indexed with several prefixes person_termer = lambda do |d| lambda do |p| doc.index_text p.name, PREFIX["#{d}_name"][:prefix] if p.name doc.index_text p.email, PREFIX['email_text'][:prefix] doc.add_term mkterm(:email, d, p.email) end end person_termer[:from][m.from] if m.from (m.to+m.cc+m.bcc).each(&(person_termer[:to])) # Full text search content subject_text = m.indexable_subject body_text = m.indexable_body doc.index_text subject_text, PREFIX['subject'][:prefix] doc.index_text body_text, PREFIX['body'][:prefix] m.attachments.each { |a| doc.index_text a, PREFIX['attachment'][:prefix] } # Miscellaneous terms doc.add_term mkterm(:date, m.date) if m.date doc.add_term mkterm(:type, 'mail') doc.add_term mkterm(:msgid, m.id) m.attachments.each do |a| a =~ /\.(\w+)$/ or next doc.add_term mkterm(:attachment_extension, $1) end # Date value for range queries date_value = begin Xapian.sortable_serialise m.date.to_i rescue TypeError Xapian.sortable_serialise 0 end doc.add_value MSGID_VALUENO, m.id doc.add_value DATE_VALUENO, date_value end def index_message_locations doc, entry, old_entry old_entry[:locations].map { |x| x[0] }.uniq.each { |x| doc.remove_term mkterm(:source_id, x) } if old_entry entry[:locations].map { |x| x[0] }.uniq.each { |x| doc.add_term mkterm(:source_id, x) } old_entry[:locations].each { |x| (doc.remove_term mkterm(:location, *x) rescue nil) } if old_entry entry[:locations].each { |x| doc.add_term mkterm(:location, *x) } end def index_message_labels doc, new_labels, old_labels return if new_labels == old_labels added = new_labels.to_a - old_labels.to_a removed = old_labels.to_a - new_labels.to_a added.each { |t| doc.add_term mkterm(:label,t) } removed.each { |t| doc.remove_term mkterm(:label,t) } end ## Assign a set of thread ids to the document. This is a hybrid of the runtime ## search done by the Ferret index and the index-time union done by previous ## versions of the Xapian index. We first find the thread ids of all messages ## with a reference to or from us. If that set is empty, we use our own ## message id. Otherwise, we use all the thread ids we previously found. In ## the common case there's only one member in that set, but if we're the ## missing link between multiple previously unrelated threads we can have ## more. XapianIndex#each_message_in_thread_for follows the thread ids when ## searching so the user sees a single unified thread. def index_message_threading doc, entry, old_entry return if old_entry && (entry[:refs] == old_entry[:refs]) && (entry[:replytos] == old_entry[:replytos]) children = term_docids(mkterm(:ref, entry[:message_id])).map { |docid| @xapian.document docid } parent_ids = entry[:refs] + entry[:replytos] parents = parent_ids.map { |id| find_doc id }.compact thread_members = SavingHash.new { [] } (children + parents).each do |doc2| thread_ids = doc2.value(THREAD_VALUENO).split ',' thread_ids.each { |thread_id| thread_members[thread_id] << doc2 } end thread_ids = thread_members.empty? ? [entry[:message_id]] : thread_members.keys thread_ids.each { |thread_id| doc.add_term mkterm(:thread, thread_id) } parent_ids.each { |ref| doc.add_term mkterm(:ref, ref) } doc.add_value THREAD_VALUENO, (thread_ids * ',') end def truncate_date date if date < MIN_DATE debug "warning: adjusting too-low date #{date} for indexing" MIN_DATE elsif date > MAX_DATE debug "warning: adjusting too-high date #{date} for indexing" MAX_DATE else date end end # Construct a Xapian term def mkterm type, *args case type when :label PREFIX['label'][:prefix] + args[0].to_s.downcase when :type PREFIX['type'][:prefix] + args[0].to_s.downcase when :date PREFIX['date'][:prefix] + args[0].getutc.strftime("%Y%m%d%H%M%S") when :email case args[0] when :from then PREFIX['from_email'][:prefix] when :to then PREFIX['to_email'][:prefix] else raise "Invalid email term type #{args[0]}" end + args[1].to_s.downcase when :source_id PREFIX['source_id'][:prefix] + args[0].to_s.downcase when :location PREFIX['location'][:prefix] + [args[0]].pack('n') + args[1].to_s when :attachment_extension PREFIX['attachment_extension'][:prefix] + args[0].to_s.downcase when :msgid, :ref, :thread PREFIX[type.to_s][:prefix] + args[0][0...(MAX_TERM_LENGTH-1)] else raise "Invalid term type #{type}" end end end end class Xapian::Document def entry Marshal.load data end def entry=(x) self.data = Marshal.dump x end def index_text text, prefix, weight=1 term_generator = Xapian::TermGenerator.new term_generator.stemmer = Xapian::Stem.new($config[:stem_language]) term_generator.document = self term_generator.index_text text, weight, prefix end alias old_add_term add_term def add_term term if term.length <= Redwood::Index::MAX_TERM_LENGTH old_add_term term, 0 else warn "dropping excessively long term #{term}" end end end sup-1.1/lib/sup/time.rb0000644000004100000410000000427014246427237015070 0ustar www-datawww-dataclass Time Redwood::HookManager.register "time-to-nice-string", < self, :from => from) || default_to_nice_s(from) end def default_to_nice_s from=Time.now if year != from.year strftime "%b %Y" elsif month != from.month strftime "%b %e" else if is_the_same_day? from format = $config[:time_mode] == "24h" ? "%k:%M" : "%l:%M%p" strftime(format).downcase elsif is_the_day_before? from format = $config[:time_mode] == "24h" ? "%kh" : "%l%p" "Yest." + nearest_hour.strftime(format).downcase else strftime "%b %e" end end end ## This is how a message date is displayed in thread-view-mode def to_message_nice_s from=Time.now format = $config[:time_mode] == "24h" ? "%B %e %Y %k:%M" : "%B %e %Y %l:%M%p" strftime format end end sup-1.1/lib/sup/service/0000755000004100000410000000000014246427237015242 5ustar www-datawww-datasup-1.1/lib/sup/service/label_service.rb0000644000004100000410000000165014246427237020370 0ustar www-datawww-datarequire "sup/index" module Redwood # Provides label tweaking service to the user. # Working as the backend of ConsoleMode. # # Should become the backend of bin/sup-tweak-labels in the future. class LabelService # @param index [Redwood::Index] def initialize index=Index.instance @index = index end def add_labels query, *labels run_on_each_message(query) do |m| labels.each {|l| m.add_label l } end end def remove_labels query, *labels run_on_each_message(query) do |m| labels.each {|l| m.remove_label l } end end private def run_on_each_message query, &operation count = 0 find_messages(query).each do |m| operation.call(m) @index.update_message_state m count += 1 end @index.save_index count end def find_messages query @index.find_messages(query) end end end sup-1.1/lib/sup/colormap.rb0000644000004100000410000002031614246427237015745 0ustar www-datawww-datamodule Ncurses COLOR_DEFAULT = -1 NUM_COLORS = `tput colors`.to_i MAX_PAIRS = `tput pairs`.to_i def self.color! name, value const_set "COLOR_#{name.to_s.upcase}", value end ## numeric colors Ncurses::NUM_COLORS.times { |x| color! x, x } if Ncurses::NUM_COLORS == 256 ## xterm 6x6x6 color cube 6.times { |x| 6.times { |y| 6.times { |z| color! "c#{x}#{y}#{z}", 16 + z + 6*y + 36*x } } } ## xterm 24-shade grayscale 24.times { |x| color! "g#{x}", (16+6*6*6) + x } elsif Ncurses::NUM_COLORS == -1 ## Terminal emulator doesn't appear to support colors fail "sup must be run in a terminal with color support, please check your TERM variable." end end module Redwood class Colormap @@instance = nil DEFAULT_COLORS = { :text => { :fg => "white", :bg => "black" }, :status => { :fg => "white", :bg => "blue", :attrs => ["bold"] }, :index_old => { :fg => "white", :bg => "default" }, :index_new => { :fg => "white", :bg => "default", :attrs => ["bold"] }, :index_starred => { :fg => "yellow", :bg => "default", :attrs => ["bold"] }, :index_draft => { :fg => "red", :bg => "default", :attrs => ["bold"] }, :labellist_old => { :fg => "white", :bg => "default" }, :labellist_new => { :fg => "white", :bg => "default", :attrs => ["bold"] }, :twiddle => { :fg => "blue", :bg => "default" }, :label => { :fg => "yellow", :bg => "default" }, :message_patina => { :fg => "black", :bg => "green" }, :alternate_patina => { :fg => "black", :bg => "blue" }, :missing_message => { :fg => "black", :bg => "red" }, :attachment => { :fg => "cyan", :bg => "default" }, :cryptosig_valid => { :fg => "yellow", :bg => "default", :attrs => ["bold"] }, :cryptosig_valid_untrusted => { :fg => "yellow", :bg => "blue", :attrs => ["bold"] }, :cryptosig_unknown => { :fg => "cyan", :bg => "default" }, :cryptosig_invalid => { :fg => "yellow", :bg => "red", :attrs => ["bold"] }, :generic_notice_patina => { :fg => "cyan", :bg => "default" }, :quote_patina => { :fg => "yellow", :bg => "default" }, :sig_patina => { :fg => "yellow", :bg => "default" }, :quote => { :fg => "yellow", :bg => "default" }, :sig => { :fg => "yellow", :bg => "default" }, :to_me => { :fg => "green", :bg => "default" }, :with_attachment => { :fg => "green", :bg => "default" }, :starred => { :fg => "yellow", :bg => "default", :attrs => ["bold"] }, :starred_patina => { :fg => "yellow", :bg => "green", :attrs => ["bold"] }, :alternate_starred_patina => { :fg => "yellow", :bg => "blue", :attrs => ["bold"] }, :snippet => { :fg => "cyan", :bg => "default" }, :option => { :fg => "white", :bg => "default" }, :tagged => { :fg => "yellow", :bg => "default", :attrs => ["bold"] }, :draft_notification => { :fg => "red", :bg => "default", :attrs => ["bold"] }, :completion_character => { :fg => "white", :bg => "default", :attrs => ["bold"] }, :horizontal_selector_selected => { :fg => "yellow", :bg => "default", :attrs => ["bold"] }, :horizontal_selector_unselected => { :fg => "cyan", :bg => "default" }, :search_highlight => { :fg => "black", :bg => "yellow", :attrs => ["bold"] }, :system_buf => { :fg => "blue", :bg => "default" }, :regular_buf => { :fg => "white", :bg => "default" }, :modified_buffer => { :fg => "yellow", :bg => "default", :attrs => ["bold"] }, :date => { :fg => "white", :bg => "default"}, :size_widget => { :fg => "white", :bg => "default"}, } def initialize raise "only one instance can be created" if @@instance @@instance = self @color_pairs = {[Ncurses::COLOR_WHITE, Ncurses::COLOR_BLACK] => 0} @users = [] @next_id = 0 reset yield self if block_given? end def reset @entries = {} @highlights = { :none => highlight_sym(:none)} @entries[highlight_sym(:none)] = highlight_for(Ncurses::COLOR_WHITE, Ncurses::COLOR_BLACK, []) + [nil] end def add sym, fg, bg, attr=nil, highlight=nil raise ArgumentError, "color for #{sym} already defined" if @entries.member? sym raise ArgumentError, "color '#{fg}' unknown" unless (-1...Ncurses::NUM_COLORS).include? fg raise ArgumentError, "color '#{bg}' unknown" unless (-1...Ncurses::NUM_COLORS).include? bg attrs = [attr].flatten.compact @entries[sym] = [fg, bg, attrs, nil] if not highlight highlight = highlight_sym(sym) @entries[highlight] = highlight_for(fg, bg, attrs) + [nil] end @highlights[sym] = highlight end def highlight_sym sym "#{sym}_highlight".intern end def highlight_for fg, bg, attrs hfg = case fg when Ncurses::COLOR_BLUE Ncurses::COLOR_WHITE when Ncurses::COLOR_YELLOW, Ncurses::COLOR_GREEN fg else Ncurses::COLOR_BLACK end hbg = case bg when Ncurses::COLOR_CYAN Ncurses::COLOR_YELLOW when Ncurses::COLOR_YELLOW Ncurses::COLOR_BLUE else Ncurses::COLOR_CYAN end attrs = if fg == Ncurses::COLOR_WHITE && attrs.include?(Ncurses::A_BOLD) [Ncurses::A_BOLD] else case hfg when Ncurses::COLOR_BLACK [] else [Ncurses::A_BOLD] end end [hfg, hbg, attrs] end def color_for sym, highlight=false sym = @highlights[sym] if highlight return Ncurses::COLOR_BLACK if sym == :none raise ArgumentError, "undefined color #{sym}" unless @entries.member? sym ## if this color is cached, return it fg, bg, attrs, color = @entries[sym] return color if color if(cp = @color_pairs[[fg, bg]]) ## nothing else ## need to get a new colorpair @next_id = (@next_id + 1) % Ncurses::MAX_PAIRS @next_id += 1 if @next_id == 0 # 0 is always white on black id = @next_id debug "colormap: for color #{sym}, using id #{id} -> #{fg}, #{bg}" Ncurses.init_pair id, fg, bg or raise ArgumentError, "couldn't initialize curses color pair #{fg}, #{bg} (key #{id})" cp = @color_pairs[[fg, bg]] = Ncurses.COLOR_PAIR(id) ## delete the old mapping, if it exists if @users[cp] @users[cp].each do |usym| warn "dropping color #{usym} (#{id})" @entries[usym][3] = nil end @users[cp] = [] end end ## by now we have a color pair color = attrs.inject(cp) { |color, attr| color | attr } @entries[sym][3] = color # fill the cache (@users[cp] ||= []) << sym # record entry as a user of that color pair color end def sym_is_defined sym return sym if @entries.member? sym end ## Try to use the user defined colors, in case of an error fall back ## to the default ones. def populate_colormap user_colors = if File.exist? Redwood::COLOR_FN debug "loading user colors from #{Redwood::COLOR_FN}" Redwood::load_yaml_obj Redwood::COLOR_FN end ## Set attachment sybmol to sane default for existing colorschemes if user_colors and user_colors.has_key? :to_me user_colors[:with_attachment] = user_colors[:to_me] unless user_colors.has_key? :with_attachment end Colormap::DEFAULT_COLORS.merge(user_colors||{}).each_pair do |k, v| fg = begin Ncurses.const_get "COLOR_#{v[:fg].to_s.upcase}" rescue NameError warn "there is no color named \"#{v[:fg]}\"" Ncurses::COLOR_GREEN end bg = begin Ncurses.const_get "COLOR_#{v[:bg].to_s.upcase}" rescue NameError warn "there is no color named \"#{v[:bg]}\"" Ncurses::COLOR_RED end attrs = (v[:attrs]||[]).map do |a| begin Ncurses.const_get "A_#{a.upcase}" rescue NameError warn "there is no attribute named \"#{a}\", using fallback." nil end end.compact highlight_symbol = v[:highlight] ? :"#{v[:highlight]}_color" : nil symbol = (k.to_s + "_color").to_sym add symbol, fg, bg, attrs, highlight_symbol end end def self.instance; @@instance; end def self.method_missing meth, *a Colormap.new unless @@instance @@instance.send meth, *a end # Performance shortcut def self.color_for(*a); @@instance.color_for(*a); end end end sup-1.1/lib/sup/poll.rb0000644000004100000410000002366614246427237015112 0ustar www-datawww-datarequire 'thread' module Redwood class PollManager include Redwood::Singleton HookManager.register "before-add-message", < num, :num_inbox => numi, :num_total => @running_totals[:num], :num_inbox_total => @running_totals[:numi], :num_updated => @running_totals[:numu], :num_deleted => @running_totals[:numd], :labels => @running_totals[:loaded_labels], :from_and_subj => from_and_subj, :from_and_subj_inbox => from_and_subj_inbox, :num_inbox_total_unread => lambda { Index.num_results_for :labels => [:inbox, :unread] } } HookManager.run("after-poll", hook_args) else if @running_totals[:num] > 0 flash_msg = "Loaded #{@running_totals[:num].pluralize 'new message'}, #{@running_totals[:numi]} to inbox. " if @running_totals[:num] > 0 flash_msg += "Updated #{@running_totals[:numu].pluralize 'message'}. " if @running_totals[:numu] > 0 flash_msg += "Deleted #{@running_totals[:numd].pluralize 'message'}. " if @running_totals[:numd] > 0 flash_msg += "Labels: #{@running_totals[:loaded_labels].map{|l| l.to_s}.join(', ')}." if @running_totals[:loaded_labels].size > 0 BufferManager.flash flash_msg else BufferManager.flash "No new messages." end end end def poll if @polling.try_lock @poll_sources = SourceManager.usual_sources num, numi = poll_with_sources @polling.unlock [num, numi] else debug "poll already in progress." return end end def poll_unusual if @polling.try_lock @poll_sources = SourceManager.unusual_sources num, numi = poll_with_sources @polling.unlock [num, numi] else debug "poll_unusual already in progress." return end end def start @thread = Redwood::reporting_thread("periodic poll") do while true sleep @delay / 2 poll if @last_poll.nil? || (Time.now - @last_poll) >= @delay end end end def stop @thread.kill if @thread @thread = nil end def do_poll total_num = total_numi = total_numu = total_numd = 0 from_and_subj = [] from_and_subj_inbox = [] loaded_labels = Set.new @mutex.synchronize do @poll_sources.each do |source| begin yield "Loading from #{source}... " rescue SourceError => e warn "problem getting messages from #{source}: #{e.message}" next end msg = "" num = numi = numu = numd = 0 poll_from source do |action,m,old_m,progress| if action == :delete yield "Deleting #{m.id}" loaded_labels.merge m.labels numd += 1 elsif action == :update yield "Message at #{m.source_info} is an update of an old message. Updating labels from #{old_m.labels.to_a * ','} => #{m.labels.to_a * ','}" loaded_labels.merge m.labels numu += 1 elsif action == :add if old_m new_locations = (m.locations - old_m.locations) if not new_locations.empty? yield "Message at #{new_locations[0].info} has changed its source location. Updating labels from #{old_m.labels.to_a * ','} => #{m.labels.to_a * ','}" numu += 1 else yield "Skipping already-imported message at #{m.locations[-1].info}" end else yield "Found new message at #{m.source_info} with labels #{m.labels.to_a * ','}" loaded_labels.merge m.labels num += 1 from_and_subj << [m.from && m.from.longname, m.subj] if (m.labels & [:inbox, :spam, :deleted, :killed]) == Set.new([:inbox]) from_and_subj_inbox << [m.from && m.from.longname, m.subj] numi += 1 end end else fail end end msg += "Found #{num} messages, #{numi} to inbox. " unless num == 0 msg += "Updated #{numu} messages. " unless numu == 0 msg += "Deleted #{numd} messages." unless numd == 0 yield msg unless msg == "" total_num += num total_numi += numi total_numu += numu total_numd += numd end loaded_labels = loaded_labels - LabelManager::HIDDEN_RESERVED_LABELS - [:inbox, :killed] yield "Done polling; loaded #{total_num} new messages total" @last_poll = Time.now end [total_num, total_numi, total_numu, total_numd, from_and_subj, from_and_subj_inbox, loaded_labels] end ## like Source#poll, but yields successive Message objects, which have their ## labels and locations set correctly. The Messages are saved to or removed ## from the index after being yielded. def poll_from source, opts={} debug "trying to acquire poll lock for: #{source}..." if source.try_lock begin source.poll do |sym, args| case sym when :add m = Message.build_from_source source, args[:info] old_m = Index.build_message m.id m.labels += args[:labels] m.labels.delete :inbox if source.archived? m.labels.delete :unread if source.read? m.labels.delete :unread if m.source_marked_read? # preserve read status if possible m.labels.each { |l| LabelManager << l } m.labels = old_m.labels + (m.labels - [:unread, :inbox]) if old_m m.locations = old_m.locations + m.locations if old_m HookManager.run "before-add-message", :message => m yield :add, m, old_m, args[:progress] if block_given? Index.sync_message m, true if Index.message_joining_killed? m m.labels += [:killed] Index.sync_message m, true end ## We need to add or unhide the message when it either did not exist ## before at all or when it was updated. We do *not* add/unhide when ## the same message was found at a different location if old_m UpdateManager.relay self, :updated, m elsif !old_m or not old_m.locations.member? m.location UpdateManager.relay self, :added, m end when :delete Index.each_message({:location => [source.id, args[:info]]}, false) do |m| m.locations.delete Location.new(source, args[:info]) Index.sync_message m, false if m.locations.size == 0 yield :delete, m, [source,args[:info]], args[:progress] if block_given? Index.delete m.id UpdateManager.relay self, :location_deleted, m end end when :update Index.each_message({:location => [source.id, args[:old_info]]}, false) do |m| old_m = Index.build_message m.id m.locations.delete Location.new(source, args[:old_info]) m.locations.push Location.new(source, args[:new_info]) ## Update labels that might have been modified remotely m.labels -= source.supported_labels? m.labels += args[:labels] yield :update, m, old_m if block_given? Index.sync_message m, true UpdateManager.relay self, :updated, m end end end rescue SourceError => e warn "problem getting messages from #{source}: #{e.message}" ensure source.go_idle source.unlock end else debug "source #{source} is already being polled." end end def handle_idle_update sender, idle_since; @should_clear_running_totals = false; end def handle_unidle_update sender, idle_since; @should_clear_running_totals = true; clear_running_totals; end def clear_running_totals; @running_totals = {:num => 0, :numi => 0, :numu => 0, :numd => 0, :loaded_labels => Set.new}; end end end sup-1.1/lib/sup/hook.rb0000644000004100000410000000656014246427237015076 0ustar www-datawww-datarequire "sup/util" module Redwood class HookManager class HookContext def initialize name @__say_id = nil @__name = name @__cache = {} end def say s if BufferManager.instantiated? @__say_id = BufferManager.say s, @__say_id BufferManager.draw_screen else log s end end def flash s if BufferManager.instantiated? BufferManager.flash s else log s end end def log s info "hook[#@__name]: #{s}" end def ask_yes_or_no q if BufferManager.instantiated? BufferManager.ask_yes_or_no q else print q gets.chomp.downcase == 'y' end end def get tag HookManager.tags[tag] end def set tag, value HookManager.tags[tag] = value end def __run __hook, __filename, __locals __binding = binding __lprocs, __lvars = __locals.partition { |k, v| v.is_a?(Proc) } eval __lvars.map { |k, v| "#{k} = __locals[#{k.inspect}];" }.join, __binding ## we also support closures for delays evaluation. unfortunately ## we have to do this via method calls, so you don't get all the ## semantics of a regular variable. not ideal. __lprocs.each do |k, v| self.class.instance_eval do define_method k do @__cache[k] ||= v.call end end end ret = eval __hook, __binding, __filename BufferManager.clear @__say_id if @__say_id @__cache = {} ret end end include Redwood::Singleton @descs = {} class << self attr_reader :descs end def initialize dir @dir = dir @hooks = {} @contexts = {} @tags = {} Dir.mkdir dir unless File.exist? dir end attr_reader :tags def run name, locals={} hook = hook_for(name) or return context = @contexts[hook] ||= HookContext.new(name) result = nil fn = fn_for name begin result = context.__run hook, fn, locals rescue Exception => e log "error running #{fn}: #{e.message}" log e.backtrace.join("\n") @hooks[name] = nil # disable it BufferManager.flash "Error running hook: #{e.message}" if BufferManager.instantiated? end result end def self.register name, desc @descs[name] = desc end def print_hooks pattern="", f=$stdout matching_hooks = HookManager.descs.sort.keep_if {|name, desc| pattern.empty? or name.match(pattern)}.map do |name, desc| < 1 && fake_root adj = 1 yield :fake_root, 0, nil end @containers.each do |cont| next if cont == root fud = cont.first_useful_descendant fud.each_with_stuff do |c, d, par| ## special case here: if we're an empty root that's already ## been joined by a fake root, don't emit yield c.message, d + adj, (par ? par.message : nil) unless fake_root && c.message.nil? && root.nil? && c == fud end end end def first; each { |m, *o| return m if m }; nil; end def has_message?; any? { |m, *o| m.is_a? Message }; end def dirty?; any? { |m, *o| m && m.dirty? }; end def date; map { |m, *o| m.date if m }.compact.max; end def snippet with_snippets = select { |m, *o| m && m.snippet && !m.snippet.empty? } first_unread, * = with_snippets.select { |m, *o| m.has_label?(:unread) }.sort_by { |m, *o| m.date }.first return first_unread.snippet if first_unread last_read, * = with_snippets.sort_by { |m, *o| m.date }.last return last_read.snippet if last_read "" end def authors; map { |m, *o| m.from if m }.compact.uniq; end def apply_label t; each { |m, *o| m && m.add_label(t) }; end def remove_label t; each { |m, *o| m && m.remove_label(t) }; end def toggle_label label if has_label? label remove_label label false else apply_label label true end end def set_labels l; each { |m, *o| m && m.labels = l }; end def has_label? t; any? { |m, *o| m && m.has_label?(t) }; end def each_dirty_message; each { |m, *o| m && m.dirty? && yield(m) }; end def direct_participants map { |m, *o| [m.from] + m.to if m }.flatten.compact.uniq end def participants map { |m, *o| [m.from] + m.to + m.cc + m.bcc if m }.flatten.compact.uniq end def size; map { |m, *o| m ? 1 : 0 }.sum; end def subj; argfind { |m, *o| m && m.subj }; end def labels; inject(Set.new) { |s, (m, *o)| m ? s | m.labels : s } end def labels= l raise ArgumentError, "not a set" unless l.is_a?(Set) each { |m, *o| m && m.labels = l.dup } end def latest_message inject(nil) do |a, b| b = b.first if a.nil? b elsif b.nil? a else b.date > a.date ? b : a end end end def to_s "" end def sort_key m = latest_message m ? [-m.date.to_i, m.id] : [-Time.now.to_i, ""] end end ## recursive structure used internally to represent message trees as ## described by reply-to: and references: headers. ## ## the 'id' field is the same as the message id. but the message might ## be empty, in the case that we represent a message that was referenced ## by another message (as an ancestor) but never received. class Container attr_accessor :message, :parent, :children, :id, :thread def initialize id raise "non-String #{id.inspect}" unless id.is_a? String @id = id @message, @parent, @thread = nil, nil, nil @children = [] end def each_with_stuff parent=nil yield self, 0, parent @children.sort_by(&:sort_key).each do |c| c.each_with_stuff(self) { |cc, d, par| yield cc, d + 1, par } end end def descendant_of? o if o == self true else @parent && @parent.descendant_of?(o) end end def == o; Container === o && id == o.id; end def empty?; @message.nil?; end def root?; @parent.nil?; end def root; root? ? self : @parent.root; end ## skip over any containers which are empty and have only one child. we use ## this make the threaded display a little nicer, and only stick in the ## "missing message" line when it's graphically necessary, i.e. when the ## missing message has more than one descendent. def first_useful_descendant if empty? && @children.size == 1 @children.first.first_useful_descendant else self end end def find_attr attr if empty? @children.argfind { |c| c.find_attr attr } else @message.send attr end end def subj; find_attr :subj; end def date; find_attr :date; end def is_reply?; subj && Message.subj_is_reply?(subj); end def to_s [ "<#{id}", (@parent.nil? ? nil : "parent=#{@parent.id}"), (@children.empty? ? nil : "children=#{@children.map { |c| c.id }.inspect}"), ].compact.join(" ") + ">" end def dump_recursive f=$stdout, indent=0, root=true, parent=nil raise "inconsistency" unless parent.nil? || parent.children.include?(self) unless root f.print " " * indent f.print "+->" end line = "[#{thread.nil? ? ' ' : '*'}] " + #"[#{useful? ? 'U' : ' '}] " + if @message message.subj ##{@message.refs.inspect} / #{@message.replytos.inspect}" else "" end f.puts "#{id} #{line}"#[0 .. (105 - indent)] indent += 3 @children.each { |c| c.dump_recursive f, indent, false, self } end def sort_key empty? ? [Time.now.to_i, ""] : [@message.date.to_i, @message.id] end end ## A set of threads, so a forest. Is integrated with the index and ## builds thread structures by reading messages from it. ## ## If 'thread_by_subj' is true, puts messages with the same subject in ## one thread, even if they don't reference each other. This is ## helpful for crappy MUAs that don't set In-reply-to: or References: ## headers, but means that messages may be threaded unnecessarily. ## ## The following invariants are maintained: every Thread has at least one ## Container tree, and every Container tree has at least one Message. class ThreadSet attr_reader :num_messages bool_reader :thread_by_subj def initialize index, thread_by_subj=true @index = index @num_messages = 0 ## map from message ids to container objects @messages = SavingHash.new { |id| Container.new id } ## map from subject strings or (or root message ids) to thread objects @threads = SavingHash.new { Thread.new } @thread_by_subj = thread_by_subj end def thread_for_id mid; @messages.member?(mid) && @messages[mid].root.thread end def contains_id? id; @messages.member?(id) && !@messages[id].empty? end def thread_for m; thread_for_id m.id end def contains? m; contains_id? m.id end def threads; @threads.values end def size; @threads.size end def dump f=$stdout @threads.each do |s, t| f.puts "**********************" f.puts "** for subject #{s} **" f.puts "**********************" t.dump f end end ## link two containers def link p, c, overwrite=false if p == c || p.descendant_of?(c) || c.descendant_of?(p) # would create a loop #puts "*** linking parent #{p.id} and child #{c.id} would create a loop" return end #puts "in link for #{p.id} to #{c.id}, perform? #{c.parent.nil?} || #{overwrite}" return unless c.parent.nil? || overwrite remove_container c p.children << c c.parent = p ## if the child was previously a top-level container, it now ain't, ## so ditch our thread and kill it if necessary prune_thread_of c end private :link def remove_container c c.parent.children.delete c if c.parent # remove from tree end private :remove_container def prune_thread_of c return unless c.thread c.thread.drop c @threads.delete_if { |k, v| v == c.thread } if c.thread.empty? c.thread = nil end private :prune_thread_of def remove_id mid return unless @messages.member?(mid) c = @messages[mid] remove_container c prune_thread_of c end def remove_thread_containing_id mid return unless @messages.member?(mid) c = @messages[mid] t = c.root.thread @threads.delete_if { |key, thread| t == thread } end ## load in (at most) num number of threads from the index def load_n_threads num, opts={} @index.each_id_by_date opts do |mid, builder| break if size >= num unless num == -1 next if contains_id? mid m = builder.call load_thread_for_message m, :skip_killed => opts[:skip_killed], :load_deleted => opts[:load_deleted], :load_spam => opts[:load_spam] yield size if block_given? end end ## loads in all messages needed to thread m ## may do nothing if m's thread is killed def load_thread_for_message m, opts={} good = @index.each_message_in_thread_for m, opts do |mid, builder| next if contains_id? mid add_message builder.call end add_message m if good end ## merges in a pre-loaded thread def add_thread t raise "duplicate" if @threads.values.member? t t.each { |m, *o| add_message m } end ## merges two threads together. both must be members of this threadset. ## does its best, heuristically, to determine which is the parent. def join_threads threads return if threads.size < 2 containers = threads.map do |t| c = @messages.member?(t.first.id) ? @messages[t.first.id] : nil raise "not in threadset: #{t.first.id}" unless c && c.message c end ## use subject headers heuristically parent = containers.find { |c| !c.is_reply? } ## no thread was rooted by a non-reply, so make a fake parent parent ||= @messages["joining-ref-" + containers.map { |c| c.id }.join("-")] containers.each do |c| next if c == parent c.message.add_ref parent.id link parent, c end true end def is_relevant? m m.refs.any? { |ref_id| @messages.member? ref_id } end def delete_message message el = @messages[message.id] return unless el.message el.message = nil end ## the heart of the threading code def add_message message el = @messages[message.id] return if el.message # we've seen it before #puts "adding: #{message.id}, refs #{message.refs.inspect}" el.message = message ## link via references: (message.refs + [el.id]).inject(nil) do |prev, ref_id| ref = @messages[ref_id] link prev, ref if prev ref end ## link via in-reply-to: message.replytos.each do |ref_id| ref = @messages[ref_id] link ref, el, true break # only do the first one end root = el.root key = if thread_by_subj? Message.normalize_subj root.subj else root.id end ## check to see if the subject is still the same (in the case ## that we first added a child message with a different ## subject) if root.thread if @threads.member?(key) && @threads[key] != root.thread @threads.delete key end else thread = @threads[key] thread << root root.thread = thread end ## last bit @num_messages += 1 end end end sup-1.1/lib/sup/idle.rb0000644000004100000410000000136514246427237015051 0ustar www-datawww-datarequire 'thread' module Redwood class IdleManager include Redwood::Singleton IDLE_THRESHOLD = 60 def initialize @no_activity_since = Time.now @idle = false @thread = nil end def ping if @idle UpdateManager.relay self, :unidle, Time.at(@no_activity_since) @idle = false end @no_activity_since = Time.now end def start @thread = Redwood::reporting_thread("checking for idleness") do while true sleep 1 if !@idle and Time.now.to_i - @no_activity_since.to_i >= IDLE_THRESHOLD UpdateManager.relay self, :idle, Time.at(@no_activity_since) @idle = true end end end end def stop @thread.kill if @thread @thread = nil end end end sup-1.1/lib/sup/message_chunks.rb0000644000004100000410000002632014246427237017131 0ustar www-datawww-data# encoding: UTF-8 require 'tempfile' require 'rbconfig' require 'shellwords' ## Here we define all the "chunks" that a message is parsed ## into. Chunks are used by ThreadViewMode to render a message. Chunks ## are used for both MIME stuff like attachments, for Sup's parsing of ## the message body into text, quote, and signature regions, and for ## notices like "this message was decrypted" or "this message contains ## a valid signature"---basically, anything we want to differentiate ## at display time. ## ## A chunk can be inlineable, expandable, or viewable. If it's ## inlineable, #color and #lines are called and the output is treated ## as part of the message text. This is how Text and one-line Quotes ## and Signatures work. ## ## If it's not inlineable but is expandable, #patina_color and ## #patina_text are called to generate a "patina" (a one-line widget, ## basically), and the user can press enter to toggle the display of ## the chunk content, which is generated from #color and #lines as ## above. This is how Quote, Signature, and most widgets ## work. Exandable chunks can additionally define #initial_state to be ## :open if they want to start expanded (default is to start collapsed). ## ## If it's not expandable but is viewable, a patina is displayed using ## #patina_color and #patina_text, but no toggling is allowed. Instead, ## if #view! is defined, pressing enter on the widget calls view! and ## (if that returns false) #to_s. Otherwise, enter does nothing. This ## is how non-inlineable attachments work. ## ## Independent of all that, a chunk can be quotable, in which case it's ## included as quoted text during a reply. Text, Quotes, and mime-parsed ## attachments are quotable; Signatures are not. ## monkey-patch time: make temp files have the right extension ## Backport from Ruby 1.9.2 for versions lower than 1.8.7 if RUBY_VERSION < '1.8.7' class Tempfile def make_tmpname(prefix_suffix, n) case prefix_suffix when String prefix = prefix_suffix suffix = "" when Array prefix = prefix_suffix[0] suffix = prefix_suffix[1] else raise ArgumentError, "unexpected prefix_suffix: #{prefix_suffix.inspect}" end t = Time.now.strftime("%Y%m%d") path = "#{prefix}#{t}-#{$$}-#{rand(0x100000000).to_s(36)}" path << "-#{n}" if n path << suffix end end end module Redwood module Chunk class Attachment HookManager.register "mime-decode", < @content_type, :filename => lambda { write_to_disk }, :charset => encoded_content.charset, :sibling_types => sibling_types end @lines = nil if text text = text.encode($encoding, :invalid => :replace, :undef => :replace) begin @lines = text.gsub("\r\n", "\n").gsub(/\t/, " ").gsub(/\r/, "").split("\n") rescue Encoding::CompatibilityError @lines = text.fix_encoding!.gsub("\r\n", "\n").gsub(/\t/, " ").gsub(/\r/, "").split("\n") debug "error while decoding message text, falling back to default encoding, expect errors in encoding: #{text.fix_encoding!}" end @quotable = true end end def color; :text_color end def patina_color; :attachment_color end def patina_text if expandable? "Attachment: #{filename} (#{lines.length} lines)" else "Attachment: #{filename} (#{content_type}; #{@raw_content.size.to_human_size})" end end def safe_filename; Shellwords.escape(@filename).gsub("/", "_") end def filesafe_filename; @filename.gsub("/", "_") end ## an attachment is exapndable if we've managed to decode it into ## something we can display inline. otherwise, it's viewable. def inlineable?; false end def expandable?; !viewable? end def indexable?; expandable? end def initial_state; :open end def viewable?; @lines.nil? end def view_default! path case RbConfig::CONFIG['arch'] when /darwin/ cmd = "open #{path}" else cmd = "/usr/bin/run-mailcap --action=view #{@content_type}:#{path}" end debug "running: #{cmd.inspect}" BufferManager.shell_out(cmd) $? == 0 end def view! write_to_disk do |path| ret = HookManager.run "mime-view", :content_type => @content_type, :filename => path ret || view_default!(path) end end def write_to_disk begin # Add the original extension to the generated tempfile name only if the # extension is "safe" (won't be interpreted by the shell). Since # Tempfile.new always generates safe file names this should prevent # attacking the user with funny attachment file names. tempname = if (File.extname @filename) =~ /^\.[[:alnum:]]+$/ then ["sup-attachment", File.extname(@filename)] else "sup-attachment" end file = Tempfile.new(tempname) file.print @raw_content file.flush @@view_tempfiles.push file # make sure the tempfile is not garbage collected before sup stops yield file.path if block_given? return file.path ensure file.close end end ## used when viewing the attachment as text def to_s @lines || @raw_content end end class Text attr_reader :lines def initialize lines @lines = lines ## trim off all empty lines except one @lines.pop while @lines.length > 1 && @lines[-1] =~ /^\s*$/ && @lines[-2] =~ /^\s*$/ end def inlineable?; true end def quotable?; true end def expandable?; false end def indexable?; true end def viewable?; false end def color; :text_color end end class Quote attr_reader :lines def initialize lines @lines = lines end def inlineable?; @lines.length == 1 end def quotable?; true end def expandable?; !inlineable? end def indexable?; expandable? end def viewable?; false end def patina_color; :quote_patina_color end def patina_text; "(#{lines.length} quoted lines)" end def color; :quote_color end end class Signature attr_reader :lines def initialize lines @lines = lines end def inlineable?; @lines.length == 1 end def quotable?; false end def expandable?; !inlineable? end def indexable?; expandable? end def viewable?; false end def patina_color; :sig_patina_color end def patina_text; "(#{lines.length}-line signature)" end def color; :sig_color end end class EnclosedMessage attr_reader :lines def initialize from, to, cc, date, subj @from = !from ? "unknown sender" : from.full_address @to = !to ? "" : to.map { |p| p.full_address }.join(", ") @cc = !cc ? "" : cc.map { |p| p.full_address }.join(", ") @date = !date ? "" : date.rfc822 @subj = subj @lines = [ "From: #{@from}", "To: #{@to}", "Cc: #{@cc}", "Date: #{@date}", "Subject: #{@subj}" ] @lines.delete_if{ |line| line == 'Cc: ' } end def inlineable?; false end def quotable?; false end def expandable?; true end def indexable?; true end def initial_state; :closed end def viewable?; false end def patina_color; :generic_notice_patina_color end def patina_text "Begin enclosed message" + ( @date == "" ? "" : " sent on #{@date}" ) end def color; :quote_color end end class CryptoNotice attr_reader :lines, :status, :patina_text, :unknown_fingerprint def initialize status, description, lines=[], unknown_fingerprint=nil @status = status @patina_text = description @lines = lines @unknown_fingerprint = unknown_fingerprint end def patina_color case status when :valid then :cryptosig_valid_color when :valid_untrusted then :cryptosig_valid_untrusted_color when :invalid then :cryptosig_invalid_color else :cryptosig_unknown_color end end def color; patina_color end def inlineable?; false end def quotable?; false end def expandable?; !@lines.empty? end def indexable?; false end def viewable?; false end end end end sup-1.1/lib/sup/modes/0000755000004100000410000000000014246427237014711 5ustar www-datawww-datasup-1.1/lib/sup/modes/edit_message_async_mode.rb0000644000004100000410000000622414246427237022074 0ustar www-datawww-datamodule Redwood class EditMessageAsyncMode < LineCursorMode HookManager.register "async-edit", < to have the file path copied to the clipboard.", "", "When you have finished editing, select this buffer and press 'E'.",] run_async_hook() super() end def lines; @text.length end def [] i @text[i] end def killable? if file_being_edited? if !BufferManager.ask_yes_or_no("It appears the file is still being edited. Are you sure?") return false end end @parent_edit_mode.edit_message_async_resume true true end def unsaved? !file_being_edited? && !file_has_been_edited? end protected def edit_finished if file_being_edited? if !BufferManager.ask_yes_or_no("It appears the file is still being edited. Are you sure?") return false end end @parent_edit_mode.edit_message_async_resume BufferManager.kill_buffer buffer true end def path_to_clipboard if system("which xsel > /dev/null 2>&1") # linux/unix path IO.popen('xsel --clipboard --input', 'r+') { |clipboard| clipboard.puts(@file_path) } BufferManager.flash "Copied file path to clipboard." elsif system("which pbcopy > /dev/null 2>&1") # mac path IO.popen('pbcopy', 'r+') { |clipboard| clipboard.puts(@file_path) } BufferManager.flash "Copied file path to clipboard." else BufferManager.flash "No way to copy text to clipboard - try installing xsel." end end def run_async_hook HookManager.run("async-edit", {:file_path => @file_path}) end def file_being_edited? # check for common editor lock files vim_lock_file = File.join(File.dirname(@file_path), '.'+File.basename(@file_path)+'.swp') emacs_lock_file = File.join(File.dirname(@file_path), '.#'+File.basename(@file_path)) return true if File.exist?(vim_lock_file) || File.exist?(emacs_lock_file) false end def file_has_been_edited? File.mtime(@file_path) > @orig_mtime end end end sup-1.1/lib/sup/modes/search_list_mode.rb0000644000004100000410000001432214246427237020544 0ustar www-datawww-datamodule Redwood class SearchListMode < LineCursorMode register_keymap do |k| k.add :select_search, "Open search results", :enter k.add :reload, "Discard saved search list and reload", '@' k.add :jump_to_next_new, "Jump to next new thread", :tab k.add :toggle_show_unread_only, "Toggle between showing all saved searches and those with unread mail", 'u' k.add :delete_selected_search, "Delete selected search", "X" k.add :rename_selected_search, "Rename selected search", "r" k.add :edit_selected_search, "Edit selected search", "e" k.add :add_new_search, "Add new search", "a" end HookManager.register "search-list-filter", < 0 } || (0 ... curpos).find { |i| @searches[i][1] > 0 } if n ## jump there if necessary jump_to_line n unless n >= topline && n < botline set_cursor_pos n else BufferManager.flash "No saved searches with unread messages." end end def focus reload # make sure unread message counts are up-to-date end def handle_added_update sender, m reload end protected def toggle_show_unread_only @unread_only = !@unread_only reload end def reload regen_text buffer.mark_dirty if buffer end def regen_text @text = [] searches = SearchManager.all_searches counted = searches.map do |name| search_string = SearchManager.search_string_for name begin if SearchManager.predefined_queries.has_key? search_string query = SearchManager.predefined_queries[search_string] else query = Index.parse_query search_string end total = Index.num_results_for :qobj => query[:qobj] unread = Index.num_results_for :qobj => query[:qobj], :label => :unread rescue Index::ParseError => e BufferManager.flash "Problem: #{e.message}!" total = 0 unread = 0 end [name, search_string, total, unread] end if HookManager.enabled? "search-list-filter" counts = HookManager.run "search-list-filter", :counted => counted else counts = counted.sort_by { |n, s, t, u| n.downcase } end n_width = counts.max_of { |n, s, t, u| n.length } tmax = counts.max_of { |n, s, t, u| t } umax = counts.max_of { |n, s, t, u| u } s_width = counts.max_of { |n, s, t, u| s.length } if @unread_only counts.delete_if { | n, s, t, u | u == 0 } end @searches = [] counts.each do |name, search_string, total, unread| fmt = HookManager.run "search-list-format", :n_width => n_width, :tmax => tmax, :umax => umax, :s_width => s_width if !fmt fmt = "%#{n_width + 1}s %5d %s, %5d unread: %s" end @text << [[(unread == 0 ? :labellist_old_color : :labellist_new_color), sprintf(fmt, name, total, total == 1 ? " message" : "messages", unread, search_string)]] @searches << [name, unread] end BufferManager.flash "No saved searches with unread messages!" if counts.empty? && @unread_only end def select_search name, _num_unread = @searches[curpos] return unless name SearchResultsMode.spawn_from_query SearchManager.search_string_for(name) end def delete_selected_search name, _num_unread = @searches[curpos] return unless name reload if SearchManager.delete name end def rename_selected_search old_name, num_unread = @searches[curpos] return unless old_name if SearchManager.predefined_searches.has_key? old_name BufferManager.flash "Cannot be edited: predefined search." return end new_name = BufferManager.ask :save_search, "Rename this saved search: ", old_name return unless new_name && new_name !~ /^\s*$/ && new_name != old_name new_name.strip! unless SearchManager.valid_name? new_name BufferManager.flash "Not renamed: " + SearchManager.name_format_hint return end if SearchManager.all_searches.include? new_name BufferManager.flash "Not renamed: \"#{new_name}\" already exists" return end reload if SearchManager.rename old_name, new_name set_cursor_pos @searches.index([new_name, num_unread])||curpos end def edit_selected_search name, num_unread = @searches[curpos] return unless name if SearchManager.predefined_searches.has_key? name BufferManager.flash "Cannot be edited: predefined search." return end old_search_string = SearchManager.search_string_for name new_search_string = BufferManager.ask :search, "Edit this saved search: ", (old_search_string + " ") return unless new_search_string && new_search_string !~ /^\s*$/ && new_search_string != old_search_string reload if SearchManager.edit name, new_search_string.strip set_cursor_pos @searches.index([name, num_unread])||curpos end def add_new_search search_string = BufferManager.ask :search, "New search: " return unless search_string && search_string !~ /^\s*$/ name = BufferManager.ask :save_search, "Name this search: " return unless name && name !~ /^\s*$/ name.strip! unless SearchManager.valid_name? name BufferManager.flash "Not saved: " + SearchManager.name_format_hint return end if SearchManager.all_searches.include? name BufferManager.flash "Not saved: \"#{name}\" already exists" return end reload if SearchManager.add name, search_string.strip set_cursor_pos @searches.index(@searches.assoc(name))||curpos end end end sup-1.1/lib/sup/modes/file_browser_mode.rb0000644000004100000410000000446714246427237020737 0ustar www-datawww-datarequire 'pathname' module Redwood ## meant to be spawned via spawn_modal! class FileBrowserMode < LineCursorMode RESERVED_ROWS = 1 register_keymap do |k| k.add :back, "Go back to previous directory", "B" k.add :view, "View file", "v" k.add :select_file_or_follow_directory, "Select the highlighted file, or follow the directory", :enter k.add :reload, "Reload file list", "R" end bool_reader :done attr_reader :value def initialize dir="." @dirs = [Pathname.new(dir).realpath] @done = false @value = nil regen_text super :skip_top_rows => RESERVED_ROWS end def cwd; @dirs.last end def lines; @text.length; end def [] i; @text[i]; end protected def back return if @dirs.size == 1 @dirs.pop reload end def reload regen_text jump_to_start buffer.mark_dirty end def view _name, f = @files[curpos - RESERVED_ROWS] return unless f && f.file? begin BufferManager.spawn f.to_s, TextMode.new(f.read.ascii) rescue SystemCallError => e BufferManager.flash e.message end end def select_file_or_follow_directory _name, f = @files[curpos - RESERVED_ROWS] return unless f if f.directory? && f.to_s != "." if f.readable? @dirs.push f reload else BufferManager.flash "Permission denied - #{f.realpath}" end else begin @value = f.realpath.to_s @done = true rescue SystemCallError => e BufferManager.flash e.message end end end def regen_text @files = begin cwd.entries.sort_by do |f| [f.directory? ? 0 : 1, f.basename.to_s] end rescue SystemCallError => e BufferManager.flash "Error: #{e.message}" [Pathname.new("."), Pathname.new("..")] end.map do |f| real_f = cwd + f name = f.basename.to_s + case when real_f.symlink? "@" when real_f.directory? "/" else "" end [name, real_f] end size_width = @files.max_of { |name, f| f.human_size.length } time_width = @files.max_of { |name, f| f.human_time.length } @text = ["#{cwd}:"] + @files.map do |name, f| sprintf "%#{time_width}s %#{size_width}s %s", f.human_time, f.human_size, name end end end end sup-1.1/lib/sup/modes/compose_mode.rb0000644000004100000410000000323414246427237017711 0ustar www-datawww-data# encoding: utf-8 module Redwood class ComposeMode < EditMessageMode def initialize opts={} header = {} header["From"] = (opts[:from] || AccountManager.default_account).full_address header["To"] = opts[:to].map { |p| p.full_address }.join(", ") if opts[:to] header["Cc"] = opts[:cc].map { |p| p.full_address }.join(", ") if opts[:cc] header["Bcc"] = opts[:bcc].map { |p| p.full_address }.join(", ") if opts[:bcc] header["Subject"] = opts[:subj] if opts[:subj] header["References"] = opts[:refs].map { |r| "<#{r}>" }.join(" ") if opts[:refs] header["In-Reply-To"] = opts[:replytos].map { |r| "<#{r}>" }.join(" ") if opts[:replytos] super :header => header, :body => (opts[:body] || []) end def default_edit_message edited = super BufferManager.kill_buffer self.buffer unless edited edited end def self.spawn_nicely opts={} from = opts[:from] || (BufferManager.ask_for_account(:account, "From (default #{AccountManager.default_account.email}): ") or return if $config[:ask_for_from]) to = opts[:to] || (BufferManager.ask_for_contacts(:people, "To: ", [opts[:to_default]]) or return if ($config[:ask_for_to] != false)) cc = opts[:cc] || (BufferManager.ask_for_contacts(:people, "Cc: ") or return if $config[:ask_for_cc]) bcc = opts[:bcc] || (BufferManager.ask_for_contacts(:people, "Bcc: ") or return if $config[:ask_for_bcc]) subj = opts[:subj] || (BufferManager.ask(:subject, "Subject: ") or return if $config[:ask_for_subject]) mode = ComposeMode.new :from => from, :to => to, :cc => cc, :bcc => bcc, :subj => subj BufferManager.spawn "New Message", mode mode.default_edit_message end end end sup-1.1/lib/sup/modes/buffer_list_mode.rb0000644000004100000410000000227414246427237020553 0ustar www-datawww-datamodule Redwood class BufferListMode < LineCursorMode register_keymap do |k| k.add :jump_to_buffer, "Jump to selected buffer", :enter k.add :reload, "Reload buffer list", "@" k.add :kill_selected_buffer, "Kill selected buffer", "X" end def initialize regen_text super end def lines; @text.length end def [] i; @text[i] end def focus reload # buffers may have been killed or created since last view set_cursor_pos 0 end protected def reload regen_text buffer.mark_dirty end def regen_text @bufs = BufferManager.buffers.reject { |name, buf| buf.mode == self || buf.hidden? }.sort_by { |name, buf| buf.atime }.reverse width = @bufs.max_of { |name, buf| buf.mode.name.length } @text = @bufs.map do |name, buf| base_color = buf.system? ? :system_buf_color : :regular_buf_color [[base_color, sprintf("%#{width}s ", buf.mode.name)], [:modified_buffer_color, (buf.mode.unsaved? ? '*' : ' ')], [base_color, " " + name]] end end def jump_to_buffer BufferManager.raise_to_front @bufs[curpos][1] end def kill_selected_buffer reload if BufferManager.kill_buffer_safely @bufs[curpos][1] end end end sup-1.1/lib/sup/modes/completion_mode.rb0000644000004100000410000000255114246427237020416 0ustar www-datawww-datamodule Redwood class CompletionMode < ScrollMode INTERSTITIAL = " " def initialize list, opts={} @list = list @header = opts[:header] @prefix_len = opts[:prefix_len] @lines = nil super :slip_rows => 1, :twiddles => false end def lines update_lines unless @lines @lines.length end def [] i update_lines unless @lines @lines[i] end def roll; if at_bottom? then jump_to_start else page_down end end private def update_lines max_length = @list.max_of { |s| s.length } num_per = [1, buffer.content_width / (max_length + INTERSTITIAL.length)].max @lines = [@header].compact @list.each_with_index do |s, i| if @prefix_len @lines << [] if i % num_per == 0 if @prefix_len < s.length prefix = s[0 ... @prefix_len] suffix = s[(@prefix_len + 1) .. -1] char = s[@prefix_len].chr @lines.last += [[:text_color, sprintf("%#{max_length - suffix.length - 1}s", prefix)], [:completion_character_color, char], [:text_color, suffix + INTERSTITIAL]] else @lines.last += [[:text_color, sprintf("%#{max_length}s#{INTERSTITIAL}", s)]] end else @lines << "" if i % num_per == 0 @lines.last += sprintf "%#{max_length}s#{INTERSTITIAL}", s end end end end end sup-1.1/lib/sup/modes/thread_index_mode.rb0000644000004100000410000007152114246427237020706 0ustar www-datawww-datarequire 'set' module Redwood ## subclasses should implement: ## - is_relevant? class ThreadIndexMode < LineCursorMode DATE_WIDTH = Time::TO_NICE_S_MAX_LEN MIN_FROM_WIDTH = 15 LOAD_MORE_THREAD_NUM = 20 HookManager.register "index-mode-size-widget", < size, :when_done => lambda { |num| @last_load_more_size = num } end end def unsaved?; dirty? end def lines; @text.length; end def [] i; @text[i]; end def contains_thread? t; @threads.include?(t) end def reload drop_all_threads UndoManager.clear BufferManager.draw_screen load_threads :num => buffer.content_height end ## open up a thread view window def select t=nil, when_done=nil t ||= cursor_thread or return Redwood::reporting_thread("load messages for thread-view-mode") do num = t.size message = "Loading #{num.pluralize 'message body'}..." BufferManager.say(message) do |sid| t.each_with_index do |(m, *_), i| next unless m BufferManager.say "#{message} (#{i}/#{num})", sid if t.size > 1 m.load_from_source! end end mode = ThreadViewMode.new t, @hidden_labels, self BufferManager.spawn t.subj, mode BufferManager.draw_screen mode.jump_to_first_open if $config[:jump_to_open_message] BufferManager.draw_screen # lame TODO: make this unnecessary ## the first draw_screen is needed before topline and botline ## are set, and the second to show the cursor having moved t.remove_label :unread Index.save_thread t update_text_for_line curpos UpdateManager.relay self, :read, t.first when_done.call if when_done end end def multi_select threads threads.each { |t| select t } end ## these two methods are called by thread-view-modes when the user ## wants to view the previous/next thread without going back to ## index-mode. we update the cursor as a convenience. def launch_next_thread_after thread, &b launch_another_thread thread, 1, &b end def launch_prev_thread_before thread, &b launch_another_thread thread, -1, &b end def launch_another_thread thread, direction, &b l = @lines[thread] or return target_l = l + direction t = @mutex.synchronize do if target_l >= 0 && target_l < @threads.length @threads[target_l] end end if t # there's a next thread set_cursor_pos target_l # move out of mutex? select t, b elsif b # no next thread. call the block anyways b.call end end def handle_single_message_labeled_update sender, m ## no need to do anything different here; we don't differentiate ## messages from their containing threads handle_labeled_update sender, m end def handle_labeled_update sender, m if(t = thread_containing(m)) l = @lines[t] or return update_text_for_line l elsif is_relevant?(m) add_or_unhide m end end def handle_simple_update sender, m t = thread_containing(m) or return l = @lines[t] or return update_text_for_line l end %w(read unread archived starred unstarred).each do |state| define_method "handle_#{state}_update" do |*a| handle_simple_update(*a) end end ## overwrite me! def is_relevant? m; false; end def handle_added_update sender, m add_or_unhide m BufferManager.draw_screen end def handle_updated_update sender, m t = thread_containing(m) or return l = @lines[t] or return @ts_mutex.synchronize do @ts.delete_message m @ts.add_message m end Index.save_thread t, sync_back = false update_text_for_line l end def handle_location_deleted_update sender, m t = thread_containing(m) delete_thread t if t and t.first.id == m.id @ts_mutex.synchronize do @ts.delete_message m if t end update end def handle_single_message_deleted_update sender, m @ts_mutex.synchronize do return unless @ts.contains? m @ts.remove_id m.id end update end def handle_deleted_update sender, m t = @ts_mutex.synchronize { @ts.thread_for m } return unless t hide_thread t update end def handle_killed_update sender, m t = @ts_mutex.synchronize { @ts.thread_for m } return unless t hide_thread t update end def handle_spammed_update sender, m t = @ts_mutex.synchronize { @ts.thread_for m } return unless t hide_thread t update end def handle_undeleted_update sender, m add_or_unhide m end def handle_unkilled_update sender, m add_or_unhide m end def undo UndoManager.undo end def update old_cursor_thread = cursor_thread @mutex.synchronize do ## let's see you do THIS in python @threads = @ts.threads.select { |t| !@hidden_threads.member?(t) }.select(&:has_message?).sort_by(&:sort_key) @size_widgets = @threads.map { |t| size_widget_for_thread t } @size_widget_width = @size_widgets.max_of { |w| w.display_length } @date_widgets = @threads.map { |t| date_widget_for_thread t } @date_widget_width = @date_widgets.max_of { |w| w.display_length } end set_cursor_pos @threads.index(old_cursor_thread)||curpos regen_text end def edit_message return unless(t = cursor_thread) message, *_ = t.find { |m, *o| m.has_label? :draft } if message mode = ResumeMode.new message BufferManager.spawn "Edit message", mode else BufferManager.flash "Not a draft message!" end end ## returns an undo lambda def actually_toggle_starred t if t.has_label? :starred # if ANY message has a star t.remove_label :starred # remove from all UpdateManager.relay self, :unstarred, t.first lambda do t.first.add_label :starred UpdateManager.relay self, :starred, t.first regen_text end else t.first.add_label :starred # add only to first UpdateManager.relay self, :starred, t.first lambda do t.remove_label :starred UpdateManager.relay self, :unstarred, t.first regen_text end end end def toggle_starred t = cursor_thread or return undo = actually_toggle_starred t UndoManager.register "toggling thread starred status", undo, lambda { Index.save_thread t } update_text_for_line curpos cursor_down Index.save_thread t end def multi_toggle_starred threads UndoManager.register "toggling #{threads.size.pluralize 'thread'} starred status", threads.map { |t| actually_toggle_starred t }, lambda { threads.each { |t| Index.save_thread t } } regen_text threads.each { |t| Index.save_thread t } end ## returns an undo lambda def actually_toggle_archived t thread = t pos = curpos if t.has_label? :inbox t.remove_label :inbox UpdateManager.relay self, :archived, t.first lambda do thread.apply_label :inbox update_text_for_line pos UpdateManager.relay self,:unarchived, thread.first end else t.apply_label :inbox UpdateManager.relay self, :unarchived, t.first lambda do thread.remove_label :inbox update_text_for_line pos UpdateManager.relay self, :unarchived, thread.first end end end ## returns an undo lambda def actually_toggle_spammed t thread = t if t.has_label? :spam t.remove_label :spam add_or_unhide t.first UpdateManager.relay self, :unspammed, t.first lambda do thread.apply_label :spam self.hide_thread thread UpdateManager.relay self,:spammed, thread.first end else t.apply_label :spam hide_thread t UpdateManager.relay self, :spammed, t.first lambda do thread.remove_label :spam add_or_unhide thread.first UpdateManager.relay self,:unspammed, thread.first end end end ## returns an undo lambda def actually_toggle_deleted t if t.has_label? :deleted t.remove_label :deleted add_or_unhide t.first UpdateManager.relay self, :undeleted, t.first lambda do t.apply_label :deleted hide_thread t UpdateManager.relay self, :deleted, t.first end else t.apply_label :deleted hide_thread t UpdateManager.relay self, :deleted, t.first lambda do t.remove_label :deleted add_or_unhide t.first UpdateManager.relay self, :undeleted, t.first end end end def toggle_archived t = cursor_thread or return undo = actually_toggle_archived t UndoManager.register "deleting/undeleting thread #{t.first.id}", undo, lambda { update_text_for_line curpos }, lambda { Index.save_thread t } update_text_for_line curpos Index.save_thread t end def multi_toggle_archived threads undos = threads.map { |t| actually_toggle_archived t } UndoManager.register "deleting/undeleting #{threads.size.pluralize 'thread'}", undos, lambda { regen_text }, lambda { threads.each { |t| Index.save_thread t } } regen_text threads.each { |t| Index.save_thread t } end def toggle_new t = cursor_thread or return t.toggle_label :unread update_text_for_line curpos cursor_down Index.save_thread t end def multi_toggle_new threads threads.each { |t| t.toggle_label :unread } regen_text threads.each { |t| Index.save_thread t } end def multi_toggle_tagged threads @mutex.synchronize { @tags.drop_all_tags } regen_text end def join_threads ## this command has no non-tagged form. as a convenience, allow this ## command to be applied to tagged threads without hitting ';'. @tags.apply_to_tagged :join_threads end def multi_join_threads threads @ts.join_threads threads or return threads.each { |t| Index.save_thread t } @tags.drop_all_tags # otherwise we have tag pointers to invalid threads! update end def jump_to_next_new n = @mutex.synchronize do ((curpos + 1) ... lines).find { |i| @threads[i].has_label? :unread } || (0 ... curpos).find { |i| @threads[i].has_label? :unread } end if n ## jump there if necessary jump_to_line n unless n >= topline && n < botline set_cursor_pos n else BufferManager.flash "No new messages." end end def toggle_spam t = cursor_thread or return multi_toggle_spam [t] end ## both spam and deleted have the curious characteristic that you ## always want to hide the thread after either applying or removing ## that label. in all thread-index-views except for ## label-search-results-mode, when you mark a message as spam or ## deleted, you want it to disappear immediately; in LSRM, you only ## see deleted or spam emails, and when you undelete or unspam them ## you also want them to disappear immediately. def multi_toggle_spam threads undos = threads.map { |t| actually_toggle_spammed t } threads.each { |t| HookManager.run("mark-as-spam", :thread => t) } UndoManager.register "marking/unmarking #{threads.size.pluralize 'thread'} as spam", undos, lambda { regen_text }, lambda { threads.each { |t| Index.save_thread t } } regen_text threads.each { |t| Index.save_thread t } end def toggle_deleted t = cursor_thread or return multi_toggle_deleted [t] end ## see comment for multi_toggle_spam def multi_toggle_deleted threads undos = threads.map { |t| actually_toggle_deleted t } UndoManager.register "deleting/undeleting #{threads.size.pluralize 'thread'}", undos, lambda { regen_text }, lambda { threads.each { |t| Index.save_thread t } } regen_text threads.each { |t| Index.save_thread t } end def kill t = cursor_thread or return multi_kill [t] end def flush_index @flush_id = BufferManager.say "Flushing index..." Index.save_index BufferManager.clear @flush_id end ## m-m-m-m-MULTI-KILL def multi_kill threads UndoManager.register "killing/unkilling #{threads.size.pluralize 'threads'}" do threads.each do |t| if t.toggle_label :killed add_or_unhide t.first else hide_thread t end end.each do |t| UpdateManager.relay self, :labeled, t.first Index.save_thread t end regen_text end threads.each do |t| if t.toggle_label :killed hide_thread t else add_or_unhide t.first end end.each do |t| # send 'labeled'... this might be more specific UpdateManager.relay self, :labeled, t.first Index.save_thread t end killed, unkilled = threads.partition { |t| t.has_label? :killed }.map(&:size) BufferManager.flash "#{killed.pluralize 'thread'} killed, #{unkilled} unkilled" regen_text end def cleanup UpdateManager.unregister self if @load_thread @load_thread.kill BufferManager.clear @mbid if @mbid sleep 0.1 # TODO: necessary? BufferManager.erase_flash end dirty_threads = @mutex.synchronize { (@threads + @hidden_threads.keys).select { |t| t.dirty? } } fail "dirty threads remain" unless dirty_threads.empty? super end def toggle_tagged t = cursor_thread or return @mutex.synchronize { @tags.toggle_tag_for t } update_text_for_line curpos cursor_down end def toggle_tagged_all @mutex.synchronize { @threads.each { |t| @tags.toggle_tag_for t } } regen_text end def tag_matching query = BufferManager.ask :search, "tag threads matching (regex): " return if query.nil? || query.empty? query = begin /#{query}/i rescue RegexpError => e BufferManager.flash "error interpreting '#{query}': #{e.message}" return end @mutex.synchronize { @threads.each { |t| @tags.tag t if thread_matches?(t, query) } } regen_text end def apply_to_tagged; @tags.apply_to_tagged; end def edit_labels thread = cursor_thread or return speciall = (@hidden_labels + LabelManager::RESERVED_LABELS).uniq old_labels = thread.labels pos = curpos keepl, modifyl = thread.labels.partition { |t| speciall.member? t } user_labels = BufferManager.ask_for_labels :label, "Labels for thread: ", modifyl.sort_by {|x| x.to_s}, @hidden_labels return unless user_labels thread.labels = Set.new(keepl) + user_labels user_labels.each { |l| LabelManager << l } update_text_for_line curpos UndoManager.register "labeling thread" do thread.labels = old_labels update_text_for_line pos UpdateManager.relay self, :labeled, thread.first Index.save_thread thread end UpdateManager.relay self, :labeled, thread.first Index.save_thread thread end def multi_edit_labels threads user_labels = BufferManager.ask_for_labels :labels, "Add/remove labels (use -label to remove): ", [], @hidden_labels return unless user_labels user_labels.map! { |l| (l.to_s =~ /^-/)? [l.to_s.gsub(/^-?/, '').to_sym, true] : [l, false] } hl = user_labels.select { |(l,_)| @hidden_labels.member? l } unless hl.empty? BufferManager.flash "'#{hl}' is a reserved label!" return end old_labels = threads.map { |t| t.labels.dup } threads.each do |t| user_labels.each do |(l, to_remove)| if to_remove t.remove_label l else t.apply_label l LabelManager << l end end UpdateManager.relay self, :labeled, t.first end regen_text UndoManager.register "labeling #{threads.size.pluralize 'thread'}" do threads.zip(old_labels).map do |t, old_labels| t.labels = old_labels UpdateManager.relay self, :labeled, t.first Index.save_thread t end regen_text end threads.each { |t| Index.save_thread t } end def reply type_arg=nil t = cursor_thread or return m = t.latest_message return if m.nil? # probably won't happen m.load_from_source! mode = ReplyMode.new m, type_arg BufferManager.spawn "Reply to #{m.subj}", mode end def reply_all; reply :all; end def forward t = cursor_thread or return m = t.latest_message return if m.nil? # probably won't happen m.load_from_source! ForwardMode.spawn_nicely :message => m end def load_n_threads_background n=LOAD_MORE_THREAD_NUM, opts={} return if @load_thread # todo: wrap in mutex @load_thread = Redwood::reporting_thread("load threads for thread-index-mode") do num = load_n_threads n, opts opts[:when_done].call(num) if opts[:when_done] @load_thread = nil end end ## TODO: figure out @ts_mutex in this method def load_n_threads n=LOAD_MORE_THREAD_NUM, opts={} @interrupt_search = false @mbid = BufferManager.say "Searching for threads..." ts_to_load = n ts_to_load = ts_to_load + @ts.size unless n == -1 # -1 means all threads orig_size = @ts.size last_update = Time.now @ts.load_n_threads(ts_to_load, opts) do |i| if (Time.now - last_update) >= 0.25 BufferManager.say "Loaded #{i.pluralize 'thread'}...", @mbid update BufferManager.draw_screen last_update = Time.now end ::Thread.pass break if @interrupt_search end @ts.threads.each { |th| th.labels.each { |l| LabelManager << l } } update BufferManager.clear @mbid if @mbid @mbid = nil BufferManager.draw_screen @ts.size - orig_size end ignore_concurrent_calls :load_n_threads def status if (l = lines) == 0 "line 0 of 0" else "line #{curpos + 1} of #{l}" end end def cancel_search @interrupt_search = true end def load_all_threads load_threads :num => -1 end def load_threads opts={} if opts[:num].nil? n = ThreadIndexMode::LOAD_MORE_THREAD_NUM else n = opts[:num] end myopts = @load_thread_opts.merge({ :when_done => (lambda do |num| opts[:when_done].call(num) if opts[:when_done] if num > 0 BufferManager.flash "Found #{num.pluralize 'thread'}." else BufferManager.flash "No matches." end end)}) if opts[:background] || opts[:background].nil? load_n_threads_background n, myopts else load_n_threads n, myopts end end ignore_concurrent_calls :load_threads def read_and_archive return unless cursor_thread thread = cursor_thread # to make sure lambda only knows about 'old' cursor_thread was_unread = thread.labels.member? :unread UndoManager.register "reading and archiving thread" do thread.apply_label :inbox thread.apply_label :unread if was_unread add_or_unhide thread.first Index.save_thread thread end cursor_thread.remove_label :unread cursor_thread.remove_label :inbox hide_thread cursor_thread regen_text Index.save_thread thread end def multi_read_and_archive threads old_labels = threads.map { |t| t.labels.dup } threads.each do |t| t.remove_label :unread t.remove_label :inbox hide_thread t end regen_text UndoManager.register "reading and archiving #{threads.size.pluralize 'thread'}" do threads.zip(old_labels).each do |t, l| t.labels = l add_or_unhide t.first Index.save_thread t end regen_text end threads.each { |t| Index.save_thread t } end def resize rows, cols regen_text super end protected def add_or_unhide m @ts_mutex.synchronize do if (is_relevant?(m) || @ts.is_relevant?(m)) && !@ts.contains?(m) @ts.load_thread_for_message m, @load_thread_opts end @hidden_threads.delete @ts.thread_for(m) end update end def thread_containing m; @ts_mutex.synchronize { @ts.thread_for m } end ## used to tag threads by query. this can be made a lot more sophisticated, ## but for right now we'll do the obvious this. def thread_matches? t, query t.subj =~ query || t.snippet =~ query || t.participants.any? { |x| x.longname =~ query } end def size_widget_for_thread t HookManager.run("index-mode-size-widget", :thread => t) || default_size_widget_for(t) end def date_widget_for_thread t HookManager.run("index-mode-date-widget", :thread => t) || default_date_widget_for(t) end def cursor_thread; @mutex.synchronize { @threads[curpos] }; end def drop_all_threads @tags.drop_all_tags initialize_threads update end def delete_thread t @mutex.synchronize do i = @threads.index(t) or return @threads.delete_at i @size_widgets.delete_at i @date_widgets.delete_at i @tags.drop_tag_for t end end def hide_thread t @mutex.synchronize do i = @threads.index(t) or return raise "already hidden" if @hidden_threads[t] @hidden_threads[t] = true @threads.delete_at i @size_widgets.delete_at i @date_widgets.delete_at i @tags.drop_tag_for t end end def update_text_for_line l return unless l # not sure why this happens, but it does, occasionally need_update = false @mutex.synchronize do # and certainly not sure why this happens.. # # probably a race condition between thread modification and updating # going on. return if @threads[l].empty? @size_widgets[l] = size_widget_for_thread @threads[l] @date_widgets[l] = date_widget_for_thread @threads[l] ## if a widget size has increased, we need to redraw everyone need_update = (@size_widgets[l].size > @size_widget_width) or (@date_widgets[l].size > @date_widget_width) end if need_update update else @text[l] = text_for_thread_at l buffer.mark_dirty if buffer end end def regen_text threads = @mutex.synchronize { @threads } @text = threads.map_with_index { |t, i| text_for_thread_at i } @lines = threads.map_with_index { |t, i| [t, i] }.to_h buffer.mark_dirty if buffer end def authors; map { |m, *o| m.from if m }.compact.uniq; end ## preserve author order from the thread def author_names_and_newness_for_thread t, limit=nil new = {} seen = {} authors = t.map do |m, *o| next unless m && m.from new[m.from] ||= m.has_label?(:unread) next if seen[m.from] seen[m.from] = true m.from end.compact result = [] authors.each do |a| break if limit && result.size >= limit name = if AccountManager.is_account?(a) "me" elsif t.authors.size == 1 a.mediumname else a.shortname end result << [name, new[a]] end if result.size == 1 && (author_and_newness = result.assoc("me")) unless (recipients = t.participants - t.authors).empty? result = recipients.collect do |r| break if limit && result.size >= limit name = (recipients.size == 1) ? r.mediumname : r.shortname ["(#{name})", author_and_newness[1]] end end end result end AUTHOR_LIMIT = 5 def text_for_thread_at line t, size_widget, date_widget = @mutex.synchronize do [@threads[line], @size_widgets[line], @date_widgets[line]] end starred = t.has_label? :starred ## format the from column cur_width = 0 ann = author_names_and_newness_for_thread t, AUTHOR_LIMIT from = [] ann.each_with_index do |(name, newness), i| break if cur_width >= from_width last = i == ann.length - 1 abbrev = if cur_width + name.display_length > from_width name.slice_by_display_length(from_width - cur_width - 1) + "." elsif cur_width + name.display_length == from_width name.slice_by_display_length(from_width - cur_width) else if last name.slice_by_display_length(from_width - cur_width) else name.slice_by_display_length(from_width - cur_width - 1) + "," end end cur_width += abbrev.display_length if last && from_width > cur_width abbrev += " " * (from_width - cur_width) end from << [(newness ? :index_new_color : (starred ? :index_starred_color : :index_old_color)), abbrev] end is_me = AccountManager.method(:is_account?) directly_participated = t.direct_participants.any?(&is_me) participated = directly_participated || t.participants.any?(&is_me) subj_color = if t.has_label?(:draft) :index_draft_color elsif t.has_label?(:unread) :index_new_color elsif starred :index_starred_color elsif Colormap.sym_is_defined(:index_subject_color) :index_subject_color else :index_old_color end size_padding = @size_widget_width - size_widget.display_length size_widget_text = sprintf "%#{size_padding}s%s", "", size_widget date_padding = @date_widget_width - date_widget.display_length date_widget_text = sprintf "%#{date_padding}s%s", "", date_widget [ [:tagged_color, @tags.tagged?(t) ? ">" : " "], [:date_color, date_widget_text], [:starred_color, (starred ? "*" : " ")], ] + from + [ [:size_widget_color, size_widget_text], [:with_attachment_color , t.labels.member?(:attachment) ? "@" : " "], [:to_me_color, directly_participated ? ">" : (participated ? '+' : " ")], ] + (t.labels - @hidden_labels).sort_by {|x| x.to_s}.map { |label| [Colormap.sym_is_defined("label_#{label}_color".to_sym) || :label_color, "#{label} "] } + [ [subj_color, t.subj + (t.subj.empty? ? "" : " ")], [:snippet_color, t.snippet], ] end def dirty?; @mutex.synchronize { (@hidden_threads.keys + @threads).any? { |t| t.dirty? } } end private def default_size_widget_for t case t.size when 1 "" else "(#{t.size})" end end def default_date_widget_for t t.date.getlocal.to_nice_s end def from_width if buffer [(buffer.content_width.to_f * 0.2).to_i, MIN_FROM_WIDTH].max else MIN_FROM_WIDTH # not sure why the buffer is gone end end def initialize_threads @ts = ThreadSet.new Index.instance, $config[:thread_by_subject] @ts_mutex = Mutex.new @hidden_threads = {} end end end sup-1.1/lib/sup/modes/text_mode.rb0000644000004100000410000000336214246427237017232 0ustar www-datawww-datamodule Redwood class TextMode < ScrollMode attr_reader :text register_keymap do |k| k.add :save_to_disk, "Save to disk", 's' k.add :pipe, "Pipe to process", '|' end def initialize text="", filename=nil @text = text @filename = filename update_lines buffer.mark_dirty if buffer super() end def save_to_disk fn = BufferManager.ask_for_filename :filename, "Save to file: ", @filename save_to_file(fn) { |f| f.puts text } if fn end def pipe command = BufferManager.ask(:shell, "pipe command: ") return if command.nil? || command.empty? output, success = pipe_to_process(command) do |stream| @text.each { |l| stream.puts l } end unless success BufferManager.flash "Invalid command: '#{command}' is not an executable" return end if output BufferManager.spawn "Output of '#{command}'", TextMode.new(output.ascii) else BufferManager.flash "'#{command}' done!" end end def text= t @text = t update_lines if buffer ensure_mode_validity buffer.mark_dirty end end def << line @lines = [0] if @text.empty? @text << line.fix_encoding! @lines << @text.length if buffer ensure_mode_validity buffer.mark_dirty end end def lines @lines.length - 1 end def [] i return nil unless i < @lines.length @text[@lines[i] ... (i + 1 < @lines.length ? @lines[i + 1] - 1 : @text.length)].normalize_whitespace # (@lines[i] ... (i + 1 < @lines.length ? @lines[i + 1] - 1 : @text.length)).inspect end private def update_lines pos = @text.find_all_positions("\n") pos.push @text.length unless pos.last == @text.length - 1 @lines = [0] + pos.map { |x| x + 1 } end end end sup-1.1/lib/sup/modes/reply_mode.rb0000644000004100000410000001645414246427237017407 0ustar www-datawww-datamodule Redwood class ReplyMode < EditMessageMode REPLY_TYPES = [:sender, :recipient, :list, :all, :user] TYPE_DESCRIPTIONS = { :sender => "Sender", :recipient => "Recipient", :all => "All", :list => "Mailing list", :user => "Customized" } HookManager.register "attribution", < @m ## sanity check that selection is a Person (or we'll fail below) ## don't check that it's an Account, though; assume they know what they're ## doing. if hook_reply_from && !(hook_reply_from.is_a? Person) info "reply-from returned non-Person, using default from." hook_reply_from = nil end ## determine the from address of a reply. ## if we have a value from a hook, use it. from = if hook_reply_from hook_reply_from ## otherwise, try and find an account somewhere in the list of to's ## and cc's and look up the corresponding name form the list of accounts. ## if this does not succeed use the recipient_email (=envelope-to) instead. ## this is for the case where mail is received from a mailing lists (so the ## To: is the list id itself). if the user subscribes via a particular ## alias, we want to use that alias in the reply. elsif(b = (@m.to.collect {|t| t.email} + @m.cc.collect {|c| c.email} + [@m.recipient_email] ).find { |p| AccountManager.is_account_email? p }) a = AccountManager.account_for(b) Person.new a.name, b ## if all else fails, use the default else AccountManager.default_account end ## now, determine to: and cc: addressess. we ignore reply-to for list ## messages because it's typically set to the list address, which we ## explicitly treat with reply type :list to = @m.is_list_message? ? @m.from : (@m.replyto || @m.from) ## next, cc: cc = (@m.to + @m.cc - [from, to]).uniq ## one potential reply type is "reply to recipient". this only happens ## in certain cases: ## if there's no cc, then the sender is the person you want to reply ## to. if it's a list message, then the list address is. otherwise, ## the cc contains a recipient. useful_recipient = !(cc.empty? || @m.is_list_message?) @headers = {} @headers[:recipient] = { "To" => cc.map { |p| p.full_address }, "Cc" => [], } if useful_recipient ## typically we don't want to have a reply-to-sender option if the sender ## is a user account. however, if the cc is empty, it's a message to ## ourselves, so for the lack of any other options, we'll add it. @headers[:sender] = { "To" => [to.full_address], "Cc" => [], } if !AccountManager.is_account?(to) || !useful_recipient @headers[:user] = { "To" => [], "Cc" => [], } not_me_ccs = cc.select { |p| !AccountManager.is_account?(p) } @headers[:all] = { "To" => [to.full_address], "Cc" => not_me_ccs.map { |p| p.full_address }, } unless not_me_ccs.empty? @headers[:list] = { "To" => [@m.list_address.full_address], "Cc" => [], } if @m.is_list_message? refs = gen_references types = REPLY_TYPES.select { |t| @headers.member?(t) } @type_selector = HorizontalSelector.new "Reply to:", types, types.map { |x| TYPE_DESCRIPTIONS[x] } hook_reply = HookManager.run "reply-to", :modes => types, :message => @m @type_selector.set_to( if types.include? type_arg type_arg elsif types.include? hook_reply hook_reply elsif @m.is_list_message? :list elsif @headers.member? :sender :sender else :recipient end) headers_full = { "From" => from.full_address, "Bcc" => [], "In-reply-to" => "<#{@m.id}>", "Subject" => Message.reify_subj(@m.subj), "References" => refs, }.merge @headers[@type_selector.val] HookManager.run "before-edit", :header => headers_full, :body => body super :header => headers_full, :body => body, :twiddles => false add_selector @type_selector end protected def move_cursor_right super if @headers[@type_selector.val] != self.header self.header = self.header.merge @headers[@type_selector.val] rerun_crypto_selector_hook update end end def move_cursor_left super if @headers[@type_selector.val] != self.header self.header = self.header.merge @headers[@type_selector.val] rerun_crypto_selector_hook update end end def reply_body_lines m attribution = HookManager.run("attribution", :message => m) || default_attribution(m) lines = attribution.split("\n") + m.quotable_body_lines.map { |l| "> #{l}" } lines.pop while lines.last =~ /^\s*$/ lines end def default_attribution m "Excerpts from #{@m.from.name}'s message of #{@m.date}:" end def handle_new_text new_header, new_body if new_body != @body_orig @body_orig = new_body @edited = true end old_header = @headers[@type_selector.val] if old_header.any? { |k, v| new_header[k] != v } @type_selector.set_to :user self.header["To"] = @headers[:user]["To"] = new_header["To"] self.header["Cc"] = @headers[:user]["Cc"] = new_header["Cc"] update end end def gen_references (@m.refs + [@m.id]).map { |x| "<#{x}>" }.join(" ") end def edit_field field edited_field = super if edited_field and (field == "To" or field == "Cc") @type_selector.set_to :user @headers[:user]["To"] = self.header["To"] @headers[:user]["Cc"] = self.header["Cc"] update end end def send_message return unless super # super returns true if the mail has been sent @m.add_label :replied Index.save_message @m end end end sup-1.1/lib/sup/modes/help_mode.rb0000644000004100000410000000041214246427237017167 0ustar www-datawww-datamodule Redwood class HelpMode < TextMode def initialize mode, global_keymap title = "Help for #{mode.name}" super < 0 } || (0 ... curpos).find { |i| @labels[i][1] > 0 } if n ## jump there if necessary jump_to_line n unless n >= topline && n < botline set_cursor_pos n else BufferManager.flash "No labels messages with unread messages." end end def focus reload # make sure unread message counts are up-to-date end def handle_added_update sender, m reload end protected def toggle_show_unread_only @unread_only = !@unread_only reload end def reload regen_text buffer.mark_dirty if buffer end def regen_text @text = [] labels = LabelManager.all_labels counted = labels.map do |label| string = LabelManager.string_for label total = Index.num_results_for :label => label unread = (label == :unread)? total : Index.num_results_for(:labels => [label, :unread]) [label, string, total, unread] end if HookManager.enabled? "label-list-filter" counts = HookManager.run "label-list-filter", :counted => counted else counts = counted.sort_by { |l, s, t, u| s.downcase } end width = counts.max_of { |l, s, t, u| s.length } tmax = counts.max_of { |l, s, t, u| t } umax = counts.max_of { |l, s, t, u| u } if @unread_only counts.delete_if { | l, s, t, u | u == 0 } end @labels = [] counts.map do |label, string, total, unread| ## if we've done a search and there are no messages for this label, we can delete it from the ## list. BUT if it's a brand-new label, the user may not have sync'ed it to the index yet, so ## don't delete it in this case. ## ## this is all a hack. what should happen is: ## TODO make the labelmanager responsible for label counts ## and then it can listen to labeled and unlabeled events, etc. if total == 0 && !LabelManager::RESERVED_LABELS.include?(label) && !LabelManager.new_label?(label) debug "no hits for label #{label}, deleting" LabelManager.delete label next end fmt = HookManager.run "label-list-format", :width => width, :tmax => tmax, :umax => umax if !fmt fmt = "%#{width + 1}s %5d %s, %5d unread" end @text << [[(unread == 0 ? :labellist_old_color : :labellist_new_color), sprintf(fmt, string, total, total == 1 ? " message" : "messages", unread)]] @labels << [label, unread] yield i if block_given? end.compact BufferManager.flash "No labels with unread messages!" if counts.empty? && @unread_only end def select_label label, _num_unread = @labels[curpos] return unless label LabelSearchResultsMode.spawn_nicely label end end end sup-1.1/lib/sup/modes/scroll_mode.rb0000644000004100000410000001651114246427237017544 0ustar www-datawww-datamodule Redwood class ScrollMode < Mode ## we define topline and botline as the top and bottom lines of any ## content in the currentview. ## we left leftcol and rightcol as the left and right columns of any ## content in the current view. but since we're operating in a ## line-centric fashion, rightcol is always leftcol + the buffer ## width. (whereas botline is topline + at most the buffer height, ## and can be == to topline in the case that there's no content.) attr_reader :status, :topline, :botline, :leftcol register_keymap do |k| k.add :line_down, "Down one line", :down, 'j', 'J', "\C-e" k.add :line_up, "Up one line", :up, 'k', 'K', "\C-y" k.add :col_left, "Left one column", :left, 'h' k.add :col_right, "Right one column", :right, 'l' k.add :page_down, "Down one page", :page_down, ' ', "\C-f" k.add :page_up, "Up one page", :page_up, 'p', :backspace, "\C-b" k.add :half_page_down, "Down one half page", "\C-d" k.add :half_page_up, "Up one half page", "\C-u" k.add :jump_to_start, "Jump to top", :home, '^', '1' k.add :jump_to_end, "Jump to bottom", :end, '$', '0' k.add :jump_to_left, "Jump to the left", '[' k.add :search_in_buffer, "Search in current buffer", '/' k.add :continue_search_in_buffer, "Jump to next search occurrence in buffer", BufferManager::CONTINUE_IN_BUFFER_SEARCH_KEY end def initialize opts={} @topline, @botline, @leftcol = 0, 0, 0 @slip_rows = opts[:slip_rows] || 0 # when we pgup/pgdown, # how many lines do we keep? @twiddles = opts.member?(:twiddles) ? opts[:twiddles] : true @search_query = nil @search_line = nil @status = "" super() end def rightcol; @leftcol + buffer.content_width; end def draw ensure_mode_validity (@topline ... @botline).each { |ln| draw_line ln, :color => :text_color } ((@botline - @topline) ... buffer.content_height).each do |ln| if @twiddles buffer.write ln, 0, "~", :color => :twiddle_color else buffer.write ln, 0, "", :color => :text_color end end @status = "lines #{@topline + 1}:#{@botline}/#{lines}" end def in_search?; @search_line end def cancel_search!; @search_line = nil end def continue_search_in_buffer unless @search_query BufferManager.flash "No current search!" return end start = @search_line || search_start_line line, col = find_text @search_query, start if line.nil? && (start > 0) line, col = find_text @search_query, 0 BufferManager.flash "Search wrapped to top!" if line end if line @search_line = line + 1 search_goto_pos line, col, col + @search_query.display_length buffer.mark_dirty else BufferManager.flash "Not found!" end end def search_in_buffer query = BufferManager.ask :search, "search in buffer: " return if query.nil? || query.empty? @search_query = Regexp.escape query continue_search_in_buffer end ## subclasses can override these three! def search_goto_pos line, leftcol, rightcol search_goto_line line if rightcol > self.rightcol # if it's occluded... jump_to_col [rightcol - buffer.content_width + 1, 0].max # move right end end def search_start_line; @topline end def search_goto_line line; jump_to_line line end def col_jump $config[:col_jump] || 2 end def col_left return unless @leftcol > 0 @leftcol -= col_jump buffer.mark_dirty end def col_right @leftcol += col_jump buffer.mark_dirty end def jump_to_col col col = col - (col % col_jump) buffer.mark_dirty unless @leftcol == col @leftcol = col end def jump_to_left; jump_to_col 0; end ## set top line to l def jump_to_line l l = l.clamp 0, lines - 1 return if @topline == l @topline = l @botline = [l + buffer.content_height, lines].min buffer.mark_dirty end def at_top?; @topline == 0 end def at_bottom?; @botline == lines end def line_down; jump_to_line @topline + 1; end def line_up; jump_to_line @topline - 1; end def page_down; jump_to_line @topline + buffer.content_height - @slip_rows; end def page_up; jump_to_line @topline - buffer.content_height + @slip_rows; end def half_page_down; jump_to_line @topline + buffer.content_height / 2; end def half_page_up; jump_to_line @topline - buffer.content_height / 2; end def jump_to_start; jump_to_line 0; end def jump_to_end; jump_to_line lines - buffer.content_height; end def ensure_mode_validity @topline = @topline.clamp 0, [lines - 1, 0].max @botline = [@topline + buffer.content_height, lines].min end def resize *a super(*a) ensure_mode_validity end protected def find_text query, start_line regex = /#{query}/i (start_line ... lines).each do |i| case(s = self[i]) when String match = s =~ regex return [i, match] if match when Array offset = 0 s.each do |color, string| match = string =~ regex if match return [i, offset + match] else offset += string.display_length end end end end nil end def draw_line ln, opts={} regex = /(#{@search_query})/i case(s = self[ln]) when String if in_search? draw_line_from_array ln, matching_text_array(s, regex), opts else draw_line_from_string ln, s, opts end when Array if in_search? ## seems like there ought to be a better way of doing this array = [] s.each do |color, text| if text =~ regex array += matching_text_array text, regex, color else array << [color, text] end end draw_line_from_array ln, array, opts else draw_line_from_array ln, s, opts end else raise "unknown drawable object: #{s.inspect} in #{self} for line #{ln}" # good for debugging end ## speed test # str = s.map { |color, text| text }.join # buffer.write ln - @topline, 0, str, :color => :none, :highlight => opts[:highlight] # return end def matching_text_array s, regex, oldcolor=:text_color s.split(regex).map do |text| next if text.empty? if text =~ regex [:search_highlight_color, text] else [oldcolor, text] end end.compact + [[oldcolor, ""]] end def draw_line_from_array ln, a, opts xpos = 0 a.each_with_index do |(color, text), i| raise "nil text for color '#{color}'" if text.nil? # good for debugging l = text.display_length no_fill = i != a.size - 1 if xpos + l < @leftcol buffer.write ln - @topline, 0, "", :color => color, :highlight => opts[:highlight] elsif xpos < @leftcol ## partial buffer.write ln - @topline, 0, text[(@leftcol - xpos) .. -1], :color => color, :highlight => opts[:highlight], :no_fill => no_fill else buffer.write ln - @topline, xpos - @leftcol, text, :color => color, :highlight => opts[:highlight], :no_fill => no_fill end xpos += l end end def draw_line_from_string ln, s, opts buffer.write ln - @topline, 0, s[@leftcol .. -1], :highlight => opts[:highlight], :color => opts[:color] end end end sup-1.1/lib/sup/modes/label_search_results_mode.rb0000644000004100000410000000213614246427237022431 0ustar www-datawww-datamodule Redwood class LabelSearchResultsMode < ThreadIndexMode def initialize labels @labels = labels opts = { :labels => @labels } opts[:load_deleted] = true if labels.include? :deleted opts[:load_spam] = true if labels.include? :spam super [], opts end register_keymap do |k| k.add :refine_search, "Refine search", '|' end def refine_search label_query = @labels.size > 1 ? "(#{@labels.join('||')})" : @labels.first query = BufferManager.ask :search, "refine query: ", "+label:#{label_query} " return unless query && query !~ /^\s*$/ SearchResultsMode.spawn_from_query query end def is_relevant? m; @labels.all? { |l| m.has_label? l } end def self.spawn_nicely label label = LabelManager.label_for(label) unless label.is_a?(Symbol) case label when nil when :inbox BufferManager.raise_to_front InboxMode.instance.buffer else b, new = BufferManager.spawn_unless_exists("All threads with label '#{label}'") { LabelSearchResultsMode.new [label] } b.mode.load_threads :num => b.content_height if new end end end end sup-1.1/lib/sup/modes/forward_mode.rb0000644000004100000410000000624614246427237017716 0ustar www-datawww-datamodule Redwood class ForwardMode < EditMessageMode HookManager.register "forward-attribution", < AccountManager.default_account.full_address, } @m = opts[:message] header["Subject"] = if @m "Fwd: " + @m.subj elsif opts[:attachments] "Fwd: " + opts[:attachments].keys.join(", ") end header["To"] = opts[:to].map { |p| p.full_address }.join(", ") if opts[:to] header["Cc"] = opts[:cc].map { |p| p.full_address }.join(", ") if opts[:cc] header["Bcc"] = opts[:bcc].map { |p| p.full_address }.join(", ") if opts[:bcc] body = if @m forward_body_lines @m elsif opts[:attachments] ["Note: #{opts[:attachments].size.pluralize 'attachment'}."] end super :header => header, :body => body, :attachments => opts[:attachments] end def self.spawn_nicely opts={} to = opts[:to] || (BufferManager.ask_for_contacts(:people, "To: ") or return if ($config[:ask_for_to] != false)) cc = opts[:cc] || (BufferManager.ask_for_contacts(:people, "Cc: ") or return if $config[:ask_for_cc]) bcc = opts[:bcc] || (BufferManager.ask_for_contacts(:people, "Bcc: ") or return if $config[:ask_for_bcc]) attachment_hash = {} attachments = opts[:attachments] || [] if(m = opts[:message]) m.load_from_source! # read the full message in. you know, maybe i should just make Message#chunks do this.... attachments += m.chunks.select { |c| c.is_a?(Chunk::Attachment) && !c.quotable? } end attachments.each do |c| mime_type = MIME::Types[c.content_type].first || MIME::Types["application/octet-stream"].first attachment_hash[c.filename] = RMail::Message.make_attachment c.raw_content, mime_type.content_type, mime_type.encoding, c.filename end mode = ForwardMode.new :message => opts[:message], :to => to, :cc => cc, :bcc => bcc, :attachments => attachment_hash title = "Forwarding " + if opts[:message] opts[:message].subj elsif attachments attachment_hash.keys.join(", ") else "something" end BufferManager.spawn title, mode mode.default_edit_message end protected def forward_body_lines m attribution = HookManager.run("forward-attribution", :message => m) || default_attribution(m) attribution[0,1] + m.quotable_header_lines + [""] + m.quotable_body_lines + attribution[1,1] end def default_attribution m ["--- Begin forwarded message from #{m.from.mediumname} ---", "--- End forwarded message ---"] end def send_message return unless super # super returns true if the mail has been sent if @m @m.add_label :forwarded Index.save_message @m end end end end sup-1.1/lib/sup/modes/resume_mode.rb0000644000004100000410000000174014246427237017544 0ustar www-datawww-datamodule Redwood class ResumeMode < EditMessageMode def initialize m @m = m @safe = false header, body = parse_file m.draft_filename header.delete "Date" super :header => header, :body => body, :have_signature => true rescue Errno::ENOENT DraftManager.discard @m BufferManager.flash "Draft deleted outside of sup." end def unsaved?; !@safe end def killable? return true if @safe case BufferManager.ask_yes_or_no "Discard draft?" when true DraftManager.discard @m BufferManager.flash "Draft discarded." true when false if edited? DraftManager.write_draft { |f| write_message f, false } DraftManager.discard @m BufferManager.flash "Draft saved." end true else false end end def send_message if super DraftManager.discard @m @safe = true end end def save_as_draft @safe = true DraftManager.discard @m if super end end end sup-1.1/lib/sup/modes/contact_list_mode.rb0000644000004100000410000000724614246427237020741 0ustar www-datawww-datamodule Redwood module CanAliasContacts def alias_contact p aalias = BufferManager.ask(:alias, "Alias for #{p.longname}: ", ContactManager.alias_for(p)) return if aalias.nil? aalias = nil if aalias.empty? # allow empty aliases name = BufferManager.ask(:name, "Name for #{p.longname}: ", p.name) return if name.nil? || name.empty? # don't allow empty names p.name = name ContactManager.update_alias p, aalias BufferManager.flash "Contact updated!" end end class ContactListMode < LineCursorMode LOAD_MORE_CONTACTS_NUM = 100 register_keymap do |k| k.add :load_more, "Load #{LOAD_MORE_CONTACTS_NUM} more contacts", 'M' k.add :reload, "Drop contact list and reload", 'D' k.add :alias, "Edit alias/or name for contact", 'a', 'i' k.add :toggle_tagged, "Tag/untag current line", 't' k.add :apply_to_tagged, "Apply next command to all tagged items", '+' k.add :search, "Search for messages from particular people", 'S' end def initialize mode=:regular @mode = mode @tags = Tagger.new self, "contact" @num = nil @text = [] super() end include CanAliasContacts def alias p = @contacts[curpos] or return alias_contact p update end def lines; @text.length; end def [] i; @text[i]; end def toggle_tagged p = @contacts[curpos] or return @tags.toggle_tag_for p update_text_for_line curpos cursor_down end def multi_toggle_tagged threads @tags.drop_all_tags update end def apply_to_tagged; @tags.apply_to_tagged; end def load_more num=LOAD_MORE_CONTACTS_NUM @num += num load update BufferManager.flash "Added #{num.pluralize 'contact'}." end def multi_select people case @mode when :regular mode = ComposeMode.new :to => people BufferManager.spawn "new message", mode mode.default_edit_message end end def select p = @contacts[curpos] or return multi_select [p] end def multi_search people mode = PersonSearchResultsMode.new people BufferManager.spawn "search for #{people.map { |p| p.name }.join(', ')}", mode mode.load_threads :num => mode.buffer.content_height end def search p = @contacts[curpos] or return multi_search [p] end def reload @tags.drop_all_tags @num = nil load end def load_in_background Redwood::reporting_thread("contact manager load in bg") do load update BufferManager.draw_screen end end def load @num ||= (buffer.content_height * 2) @user_contacts = ContactManager.contacts_with_aliases @user_contacts += (HookManager.run("extra-contact-addresses") || []).map { |addr| Person.from_address addr } num = [@num - @user_contacts.length, 0].max BufferManager.say("Loading #{num} contacts from index...") do recentc = Index.load_contacts AccountManager.user_emails, :num => num @contacts = (@user_contacts + recentc).sort_by { |p| p.sort_by_me }.uniq end end protected def update regen_text buffer.mark_dirty if buffer end def update_text_for_line line @text[line] = text_for_contact @contacts[line] buffer.mark_dirty if buffer end def text_for_contact p aalias = ContactManager.alias_for(p) || "" [[:tagged_color, @tags.tagged?(p) ? ">" : " "], [:text_color, sprintf("%-#{@awidth}s %-#{@nwidth}s %s", aalias, p.name, p.email)]] end def regen_text @awidth, @nwidth = 0, 0 @contacts.each do |p| aalias = ContactManager.alias_for(p) @awidth = aalias.length if aalias && aalias.length > @awidth @nwidth = p.name.length if p.name && p.name.length > @nwidth end @text = @contacts.map { |p| text_for_contact p } end end end sup-1.1/lib/sup/modes/log_mode.rb0000644000004100000410000000252714246427237017031 0ustar www-datawww-datarequire 'stringio' module Redwood ## a variant of text mode that allows the user to automatically follow text, ## and respawns when << is called if necessary. class LogMode < TextMode register_keymap do |k| k.add :toggle_follow, "Toggle follow mode", 'f' end ## if buffer_name is supplied, this mode will spawn a buffer ## upon receiving the << message. otherwise, it will act like ## a regular buffer. def initialize autospawn_buffer_name=nil @follow = true @autospawn_buffer_name = autospawn_buffer_name @on_kill = [] super() end ## register callbacks for when the buffer is killed def on_kill &b; @on_kill << b end def toggle_follow @follow = !@follow if @follow jump_to_line(lines - buffer.content_height + 1) # leave an empty line at bottom end buffer.mark_dirty end def << s if buffer.nil? && @autospawn_buffer_name BufferManager.spawn @autospawn_buffer_name, self, :hidden => true, :system => true end s.split("\n").each { |l| super(l + "\n") } # insane. different << semantics. if @follow follow_top = lines - buffer.content_height + 1 jump_to_line follow_top if topline < follow_top end end def status super + " (follow: #@follow)" end def cleanup @on_kill.each { |cb| cb.call self } self.text = "" super end end end sup-1.1/lib/sup/modes/edit_message_mode.rb0000644000004100000410000005257014246427237020704 0ustar www-datawww-datarequire 'tempfile' require 'socket' # just for gethostname! require 'pathname' module Redwood class SendmailCommandFailed < StandardError; end class EditMessageMode < LineCursorMode DECORATION_LINES = 1 FORCE_HEADERS = %w(From To Cc Bcc Subject) MULTI_HEADERS = %w(To Cc Bcc) NON_EDITABLE_HEADERS = %w(Message-id Date) HookManager.register "signature", < @header, :body => @body @account_selector = nil # only show account selector if there is more than one email address if $config[:account_selector] && AccountManager.user_emails.length > 1 ## Duplicate e-mail strings to prevent a "can't modify frozen ## object" crash triggered by the String::display_length() ## method in util.rb user_emails_copy = [] AccountManager.user_emails.each { |e| user_emails_copy.push e.dup } @account_selector = HorizontalSelector.new "Account:", AccountManager.user_emails + [nil], user_emails_copy + ["Customized"] if @header["From"] =~ /?$/ # TODO: this is ugly. might implement an AccountSelector and handle # special cases more transparently. account_from = @account_selector.can_set_to?($1) ? $1 : nil @account_selector.set_to account_from else @account_selector.set_to nil end # A single source of truth might better than duplicating this in both # @account_user and @account_selector. @account_user = @header["From"] add_selector @account_selector end @crypto_selector = if CryptoManager.have_crypto? HorizontalSelector.new "Crypto:", [:none] + CryptoManager::OUTGOING_MESSAGE_OPERATIONS.keys, ["None"] + CryptoManager::OUTGOING_MESSAGE_OPERATIONS.values end add_selector @crypto_selector if @crypto_selector if @crypto_selector HookManager.run "crypto-mode", :header => @header, :body => @body, :crypto_selector => @crypto_selector end super opts regen_text end def lines; @text.length + (@selectors.empty? ? 0 : (@selectors.length + DECORATION_LINES)) end def [] i if @selectors.empty? @text[i] elsif i < @selectors.length @selectors[i].line @selector_label_width elsif i == @selectors.length "" else @text[i - @selectors.length - DECORATION_LINES] end end ## hook for subclasses. i hate this style of programming. def handle_new_text header, body; end def edit_message_or_field lines = (@selectors.empty? ? 0 : DECORATION_LINES) + @selectors.size if lines > curpos return elsif (curpos - lines) >= @header_lines.length default_edit_message else edit_field @header_lines[curpos - lines] end end def edit_to; edit_field "To" end def edit_cc; edit_field "Cc" end def edit_subject; edit_field "Subject" end def save_message_to_file sig = sig_lines.join("\n") @file = Tempfile.new ["sup.#{self.class.name.gsub(/.*::/, '').camel_to_hyphy}", ".eml"] @file.puts format_headers(@header - NON_EDITABLE_HEADERS).first @file.puts begin text = @body.join("\n") rescue Encoding::CompatibilityError text = @body.map { |x| x.fix_encoding! }.join("\n") debug "encoding problem while writing message, trying to rescue, but expect errors: #{text}" end @file.puts text @file.puts sig if ($config[:edit_signature] and !@sig_edited) @file.close end def set_sig_edit_flag sig = sig_lines.join("\n") if $config[:edit_signature] pbody = @body.map { |x| x.fix_encoding! }.join("\n").fix_encoding! blen = pbody.length slen = sig.length if blen > slen and pbody[blen-slen..blen] == sig @sig_edited = false @body = pbody[0..blen-slen].fix_encoding!.split("\n") else @sig_edited = true end end end def default_edit_message if $config[:always_edit_async] return edit_message_async else return edit_message end end def alternate_edit_message if $config[:always_edit_async] return edit_message else return edit_message_async end end def edit_message old_from = @header["From"] if @account_selector begin save_message_to_file rescue SystemCallError => e BufferManager.flash "Can't save message to file: #{e.message}" return end editor = $config[:editor] || ENV['EDITOR'] || "/usr/bin/vi" mtime = File.mtime @file.path BufferManager.shell_out "#{editor} #{@file.path}" @edited = true if File.mtime(@file.path) > mtime return @edited unless @edited header, @body = parse_file @file.path @header = header - NON_EDITABLE_HEADERS set_sig_edit_flag if @account_selector and @header["From"] != old_from @account_user = @header["From"] @account_selector.set_to nil end handle_new_text @header, @body rerun_crypto_selector_hook update @edited end def edit_message_async begin save_message_to_file rescue SystemCallError => e BufferManager.flash "Can't save message to file: #{e.message}" return end @mtime = File.mtime @file.path # put up buffer saying you can now edit the message in another # terminal or app, and continue to use sup in the meantime. subject = @header["Subject"] || "" @async_mode = EditMessageAsyncMode.new self, @file.path, subject BufferManager.spawn "Waiting for message \"#{subject}\" to be finished", @async_mode # hide ourselves, and wait for signal to resume from async mode ... buffer.hidden = true end def edit_message_async_resume being_killed=false buffer.hidden = false @async_mode = nil BufferManager.raise_to_front buffer if !being_killed @edited = true if File.mtime(@file.path) > @mtime header, @body = parse_file @file.path @header = header - NON_EDITABLE_HEADERS set_sig_edit_flag handle_new_text @header, @body update true end def killable? if !@async_mode.nil? return false if !@async_mode.killable? if File.mtime(@file.path) > @mtime @edited = true header, @body = parse_file @file.path @header = header - NON_EDITABLE_HEADERS handle_new_text @header, @body update end end !edited? || BufferManager.ask_yes_or_no("Discard message?") end def unsaved?; edited? end def attach_file fn = BufferManager.ask_for_filename :attachment, "File name (enter for browser): " return unless fn if HookManager.enabled? "check-attachment" reason = HookManager.run("check-attachment", :filename => fn) if reason return unless BufferManager.ask_yes_or_no("#{reason} Attach anyway?") end end begin Dir[fn].each do |f| @attachments << RMail::Message.make_file_attachment(f) @attachment_names << f end update rescue SystemCallError => e BufferManager.flash "Can't read #{fn}: #{e.message}" end end def delete_attachment i = curpos - @attachment_lines_offset - (@selectors.empty? ? 0 : DECORATION_LINES) - @selectors.size if i >= 0 && i < @attachments.size && BufferManager.ask_yes_or_no("Delete attachment #{@attachment_names[i]}?") @attachments.delete_at i @attachment_names.delete_at i update end end protected def rerun_crypto_selector_hook if @crypto_selector && !@crypto_selector.changed_by_user HookManager.run "crypto-mode", :header => @header, :body => @body, :crypto_selector => @crypto_selector end end def mime_encode string string = [string].pack('M') # basic quoted-printable string.gsub!(/=\n/,'') # .. remove trailing newline string.gsub!(/_/,'=5F') # .. encode underscores string.gsub!(/\?/,'=3F') # .. encode question marks string.gsub!(/ /,'_') # .. translate space to underscores "=?utf-8?q?#{string}?=" end def mime_encode_subject string return string if string.ascii_only? mime_encode string end RE_ADDRESS = /(.+)( <.*@.*>)/ # Encode "bælammet mitt " into # "=?utf-8?q?b=C3=A6lammet_mitt?= def mime_encode_address string return string if string.ascii_only? string.sub(RE_ADDRESS) { |match| mime_encode($1) + $2 } end def move_cursor_left if curpos < @selectors.length @selectors[curpos].roll_left buffer.mark_dirty update if @account_selector else col_left end end def move_cursor_right if curpos < @selectors.length @selectors[curpos].roll_right buffer.mark_dirty update if @account_selector else col_right end end def add_selector s @selectors << s @selector_label_width = [@selector_label_width, s.label.length].max end def update if @account_selector if @account_selector.val.nil? @header["From"] = @account_user else @header["From"] = AccountManager.full_address_for @account_selector.val end end regen_text buffer.mark_dirty if buffer end def regen_text header, @header_lines = format_headers(@header - NON_EDITABLE_HEADERS) + [""] @text = header + [""] + @body @text += sig_lines unless @sig_edited @attachment_lines_offset = 0 unless @attachments.empty? @text += [""] @attachment_lines_offset = @text.length @text += (0 ... @attachments.size).map { |i| [[:attachment_color, "+ Attachment: #{@attachment_names[i]} (#{@attachments[i].body.size.to_human_size})"]] } end end def parse_file fn File.open(fn) do |f| header = Source.parse_raw_email_header(f).inject({}) { |h, (k, v)| h[k.capitalize] = v; h } # lousy HACK body = f.readlines.map { |l| l.chomp } header.delete_if { |k, v| NON_EDITABLE_HEADERS.member? k } header.each { |k, v| header[k] = parse_header k, v } [header, body] end end def parse_header k, v if MULTI_HEADERS.include?(k) v.split_on_commas.map do |name| (p = ContactManager.contact_for(name)) && p.full_address || name end else v end end def format_headers header header_lines = [] headers = (FORCE_HEADERS + (header.keys - FORCE_HEADERS)).map do |h| lines = make_lines "#{h}:", header[h] lines.length.times { header_lines << h } lines end.flatten.compact [headers, header_lines] end def make_lines header, things case things when nil, [] [header + " "] when String [header + " " + things] else if things.empty? [header] else things.map_with_index do |name, i| raise "an array: #{name.inspect} (things #{things.inspect})" if Array === name if i == 0 header + " " + name else (" " * (header.display_length + 1)) + name end + (i == things.length - 1 ? "" : ",") end end end end def send_message return false if !edited? && !BufferManager.ask_yes_or_no("Message unedited. Really send?") return false if $config[:confirm_no_attachments] && mentions_attachments? && @attachments.size == 0 && !BufferManager.ask_yes_or_no("You haven't added any attachments. Really send?")#" stupid ruby-mode return false if $config[:confirm_top_posting] && top_posting? && !BufferManager.ask_yes_or_no("You're top-posting. That makes you a bad person. Really send?") #" stupid ruby-mode from_email = if @header["From"] =~ /?$/ $1 else AccountManager.default_account.email end acct = AccountManager.account_for(from_email) || AccountManager.default_account BufferManager.flash "Sending..." begin date = Time.now m = build_message date if HookManager.enabled? "sendmail" if not HookManager.run "sendmail", :message => m, :account => acct warn "Sendmail hook was not successful" return false end else IO.popen(acct.sendmail, "w:UTF-8") { |p| p.puts m } raise SendmailCommandFailed, "Couldn't execute #{acct.sendmail}" unless $? == 0 end SentManager.write_sent_message(date, from_email) { |f| f.puts sanitize_body(m.to_s) } BufferManager.kill_buffer buffer BufferManager.flash "Message sent!" true rescue SystemCallError, SendmailCommandFailed, CryptoManager::Error => e warn "Problem sending mail: #{e.message}" BufferManager.flash "Problem sending mail: #{e.message}" false end end def save_as_draft DraftManager.write_draft { |f| write_message f, false } BufferManager.kill_buffer buffer BufferManager.flash "Saved for later editing." end def build_message date m = RMail::Message.new m.header["Content-Type"] = "text/plain; charset=#{$encoding}" m.body = @body.join("\n") m.body += "\n" + sig_lines.join("\n") unless @sig_edited ## body must end in a newline or GPG signatures will be WRONG! m.body += "\n" unless m.body =~ /\n\Z/ m.body = m.body.fix_encoding! ## there are attachments, so wrap body in an attachment of its own unless @attachments.empty? body_m = m body_m.header["Content-Disposition"] = "inline" m = RMail::Message.new m.add_part body_m @attachments.each do |a| a.body = a.body.fix_encoding! if a.body.kind_of? String m.add_part a end end ## do whatever crypto transformation is necessary if @crypto_selector && @crypto_selector.val != :none from_email = Person.from_address(@header["From"]).email to_email = [@header["To"], @header["Cc"], @header["Bcc"]].flatten.compact.map { |p| Person.from_address(p).email } if m.multipart? m.each_part {|p| p = transfer_encode p} else m = transfer_encode m end m = CryptoManager.send @crypto_selector.val, from_email, to_email, m end ## finally, set the top-level headers @header.each do |k, v| next if v.nil? || v.empty? m.header[k] = case v when String (k.match(/subject/i) ? mime_encode_subject(v).dup.fix_encoding! : mime_encode_address(v)).dup.fix_encoding! when Array (v.map { |v| mime_encode_address v }.join ", ").dup.fix_encoding! end end m.header["Date"] = date.rfc2822 m.header["Message-Id"] = @message_id m.header["User-Agent"] = "Sup/#{Redwood::VERSION}" m.header["Content-Transfer-Encoding"] ||= '8bit' m.header["MIME-Version"] = "1.0" if m.multipart? m end ## TODO: remove this. redundant with write_full_message_to. ## ## this is going to change soon: draft messages (currently written ## with full=false) will be output as yaml. def write_message f, full=true, date=Time.now raise ArgumentError, "no pre-defined date: header allowed" if @header["Date"] f.puts format_headers(@header).first f.puts <From ") end def mentions_attachments? if HookManager.enabled? "mentions-attachments" HookManager.run "mentions-attachments", :header => @header, :body => @body else @body.any? { |l| l.fix_encoding! =~ /^[^>]/ && l.fix_encoding! =~ /\battach(ment|ed|ing|)\b/i } end end def top_posting? @body.map { |x| x.fix_encoding! }.join("\n").fix_encoding! =~ /(\S+)\s*Excerpts from.*\n(>.*\n)+\s*\Z/ end def sig_lines p = Person.from_address(@header["From"]) from_email = p && p.email ## first run the hook hook_sig = HookManager.run "signature", :header => @header, :from_email => from_email, :message_id => @message_id return [] if hook_sig == :none return ["", "-- "] + hook_sig.split("\n") if hook_sig ## no hook, do default signature generation based on config.yaml return [] unless from_email sigfn = (AccountManager.account_for(from_email) || AccountManager.default_account).signature if sigfn && File.exist?(sigfn) ["", "-- "] + File.readlines(sigfn).map { |l| l.chomp } else [] end end def transfer_encode msg_part ## return the message unchanged if it's already encoded if (msg_part.header["Content-Transfer-Encoding"] == "base64" || msg_part.header["Content-Transfer-Encoding"] == "quoted-printable") return msg_part end ## encode to quoted-printable for all text/* MIME types, ## use base64 otherwise if msg_part.header["Content-Type"] =~ /text\/.*/ msg_part.header["Content-Transfer-Encoding"] = 'quoted-printable' msg_part.body = [msg_part.body].pack('M') else msg_part.header["Content-Transfer-Encoding"] = 'base64' msg_part.body = [msg_part.body].pack('m') end msg_part end end end sup-1.1/lib/sup/modes/thread_view_mode.rb0000644000004100000410000010234614246427237020551 0ustar www-datawww-datamodule Redwood class ThreadViewMode < LineCursorMode ## this holds all info we need to lay out a message class MessageLayout attr_accessor :top, :bot, :prev, :next, :depth, :width, :state, :color, :star_color, :orig_new, :toggled_state end class ChunkLayout attr_accessor :state end HookManager.register "detailed-headers", < $config[:slip_rows] @thread = thread @hidden_labels = hidden_labels ## used for dispatch-and-next @index_mode = index_mode @dying = false @layout = SavingHash.new { MessageLayout.new } @chunk_layout = SavingHash.new { ChunkLayout.new } earliest, latest = nil, nil latest_date = nil altcolor = false @thread.each do |m, d, p| next unless m earliest ||= m @layout[m].state = initial_state_for m @layout[m].toggled_state = false @layout[m].color = altcolor ? :alternate_patina_color : :message_patina_color @layout[m].star_color = altcolor ? :alternate_starred_patina_color : :starred_patina_color @layout[m].orig_new = m.has_label? :read altcolor = !altcolor if latest_date.nil? || m.date > latest_date latest_date = m.date latest = m end end @wrap = true @layout[latest].state = :open if @layout[latest].state == :closed @layout[earliest].state = :detailed if earliest.has_label?(:unread) || @thread.size == 1 end def toggle_wrap @wrap = !@wrap regen_text buffer.mark_dirty if buffer end def draw_line ln, opts={} if ln == curpos super ln, :highlight => true else super end end def lines; @text.length; end def [] i; @text[i]; end ## a little hacky---since regen_text can depend on buffer features like the ## content_width, we don't call it in the constructor, and instead call it ## here, which is set before we're responsible for drawing ourself. def buffer= b super regen_text end def show_header m = @message_lines[curpos] or return BufferManager.spawn_unless_exists("Full header for #{m.id}") do TextMode.new m.raw_header.ascii end end def show_message m = @message_lines[curpos] or return BufferManager.spawn_unless_exists("Raw message for #{m.id}") do TextMode.new m.raw_message.ascii end end def toggle_detailed_header m = @message_lines[curpos] or return @layout[m].state = (@layout[m].state == :detailed ? :open : :detailed) update end def reload update end def reply type_arg=nil m = @message_lines[curpos] or return mode = ReplyMode.new m, type_arg BufferManager.spawn "Reply to #{m.subj}", mode end def reply_all; reply :all; end def subscribe_to_list m = @message_lines[curpos] or return if m.list_subscribe && m.list_subscribe =~ // ComposeMode.spawn_nicely :from => AccountManager.account_for(m.recipient_email), :to => [Person.from_address($1)], :subj => ($3 || "subscribe") else BufferManager.flash "Can't find List-Subscribe header for this message." end end def unsubscribe_from_list m = @message_lines[curpos] or return BufferManager.flash "Can't find List-Unsubscribe header for this message." unless m.list_unsubscribe if m.list_unsubscribe =~ // ComposeMode.spawn_nicely :from => AccountManager.account_for(m.recipient_email), :to => [Person.from_address($1)], :subj => ($3 || "unsubscribe") elsif m.list_unsubscribe =~ /<(http.*)?>/ unless HookManager.enabled? "goto" BufferManager.flash "You must add a goto.rb hook before you can goto an unsubscribe URI." return end begin u = URI.parse($1) rescue URI::InvalidURIError BufferManager.flash("Invalid unsubscribe link") return end HookManager.run "goto", :uri => Shellwords.escape(u.to_s) end end def forward if(chunk = @chunk_lines[curpos]) && chunk.is_a?(Chunk::Attachment) ForwardMode.spawn_nicely :attachments => [chunk] elsif(m = @message_lines[curpos]) ForwardMode.spawn_nicely :message => m end end def bounce m = @message_lines[curpos] or return to = BufferManager.ask_for_contacts(:people, "Bounce To: ") or return defcmd = AccountManager.default_account.bounce_sendmail cmd = case (hookcmd = HookManager.run "bounce-command", :from => m.from, :to => to) when nil, /^$/ then defcmd else hookcmd end + ' ' + to.map { |t| t.email }.join(' ') bt = to.size > 1 ? "#{to.size} recipients" : to[0].to_s if BufferManager.ask_yes_or_no "Really bounce to #{bt}?" debug "bounce command: #{cmd}" begin IO.popen(cmd, 'w') do |sm| sm.puts m.raw_message end raise SendmailCommandFailed, "Couldn't execute #{cmd}" unless $? == 0 m.add_label :forwarded Index.save_message m rescue SystemCallError, SendmailCommandFailed => e warn "problem sending mail: #{e.message}" BufferManager.flash "Problem sending mail: #{e.message}" end end end include CanAliasContacts def alias p = @person_lines[curpos] or return alias_contact p update end def search p = @person_lines[curpos] or return mode = PersonSearchResultsMode.new [p] BufferManager.spawn "Search for #{p.name}", mode mode.load_threads :num => mode.buffer.content_height end def compose p = @person_lines[curpos] if p ComposeMode.spawn_nicely :to_default => p else ComposeMode.spawn_nicely end end def edit_labels old_labels = @thread.labels reserved_labels = old_labels.select { |l| LabelManager::RESERVED_LABELS.include? l } new_labels = BufferManager.ask_for_labels :label, "Labels for thread: ", @thread.labels.sort_by {|x| x.to_s} return unless new_labels @thread.labels = Set.new(reserved_labels) + new_labels new_labels.each { |l| LabelManager << l } update UpdateManager.relay self, :labeled, @thread.first Index.save_thread @thread UndoManager.register "labeling thread" do @thread.labels = old_labels Index.save_thread @thread UpdateManager.relay self, :labeled, @thread.first end end def toggle_starred m = @message_lines[curpos] or return toggle_label m, :starred end def toggle_new m = @message_lines[curpos] or return toggle_label m, :unread end def toggle_label m, label if m.has_label? label m.remove_label label else m.add_label label end ## TODO: don't recalculate EVERYTHING just to add a stupid little ## star to the display update UpdateManager.relay self, :single_message_labeled, m Index.save_thread @thread end ## called when someone presses enter when the cursor is highlighting ## a chunk. for expandable chunks (including messages) we toggle ## open/closed state; for viewable chunks (like attachments) we ## view. def activate_chunk chunk = @chunk_lines[curpos] or return if chunk.is_a? Chunk::Text ## if the cursor is over a text region, expand/collapse the ## entire message chunk = @message_lines[curpos] end layout = if chunk.is_a?(Message) @layout[chunk] elsif chunk.expandable? @chunk_layout[chunk] end if layout layout.state = (layout.state != :closed ? :closed : :open) #cursor_down if layout.state == :closed # too annoying update elsif chunk.viewable? view chunk end if chunk.is_a?(Message) && $config[:jump_to_open_message] jump_to_message chunk jump_to_next_open if layout.state == :closed end end def edit_as_new m = @message_lines[curpos] or return mode = ComposeMode.new(:body => m.quotable_body_lines, :to => m.to, :cc => m.cc, :subj => m.subj, :bcc => m.bcc, :refs => m.refs, :replytos => m.replytos) BufferManager.spawn "edit as new", mode mode.default_edit_message end def save_to_disk chunk = @chunk_lines[curpos] or return case chunk when Chunk::Attachment default_dir = $config[:default_attachment_save_dir] default_dir = ENV["HOME"] if default_dir.nil? || default_dir.empty? default_fn = File.expand_path File.join(default_dir, chunk.filesafe_filename) fn = BufferManager.ask_for_filename :filename, "Save attachment to file or directory: ", default_fn, true # if user selects directory use file name from message if fn and File.directory? fn fn = File.join(fn, chunk.filename) end save_to_file(fn) { |f| f.print chunk.raw_content } if fn else m = @message_lines[curpos] fn = BufferManager.ask_for_filename :filename, "Save message to file: " return unless fn save_to_file(fn) do |f| m.each_raw_message_line { |l| f.print l } end end end def save_all_to_disk m = @message_lines[curpos] or return default_dir = ($config[:default_attachment_save_dir] || ".") folder = BufferManager.ask_for_filename :filename, "Save all attachments to folder: ", default_dir, true return unless folder num = 0 num_errors = 0 m.chunks.each do |chunk| next unless chunk.is_a?(Chunk::Attachment) fn = File.join(folder, chunk.filesafe_filename) num_errors += 1 unless save_to_file(fn, false) { |f| f.print chunk.raw_content } num += 1 end if num == 0 BufferManager.flash "Didn't find any attachments!" else if num_errors == 0 BufferManager.flash "Wrote #{num.pluralize 'attachment'} to #{folder}." else BufferManager.flash "Wrote #{(num - num_errors).pluralize 'attachment'} to #{folder}; couldn't write #{num_errors} of them (see log)." end end end def publish chunk = @chunk_lines[curpos] or return if HookManager.enabled? "publish" HookManager.run "publish", :chunk => chunk else BufferManager.flash "Publishing hook not defined." end end def edit_draft m = @message_lines[curpos] or return if m.is_draft? mode = ResumeMode.new m BufferManager.spawn "Edit message", mode BufferManager.kill_buffer self.buffer mode.default_edit_message else BufferManager.flash "Not a draft message!" end end def send_draft m = @message_lines[curpos] or return if m.is_draft? mode = ResumeMode.new m BufferManager.spawn "Send message", mode BufferManager.kill_buffer self.buffer mode.send_message else BufferManager.flash "Not a draft message!" end end def jump_to_first_open m = @message_lines[0] or return if @layout[m].state != :closed jump_to_message m#, true else jump_to_next_open #true end end def jump_to_next_and_open return continue_search_in_buffer if in_search? # err.. don't know why im doing this m = (curpos ... @message_lines.length).argfind { |i| @message_lines[i] } return unless m nextm = @layout[m].next return unless nextm if @layout[m].toggled_state == true @layout[m].state = :closed @layout[m].toggled_state = false update end if @layout[nextm].state == :closed @layout[nextm].state = :open @layout[nextm].toggled_state = true end jump_to_message nextm if nextm update if @layout[nextm].toggled_state end def jump_to_next_open force_alignment=nil return continue_search_in_buffer if in_search? # hack: allow 'n' to apply to both operations m = (curpos ... @message_lines.length).argfind { |i| @message_lines[i] } return unless m while nextm = @layout[m].next break if @layout[nextm].state != :closed m = nextm end jump_to_message nextm, force_alignment if nextm end def align_current_message m = @message_lines[curpos] or return jump_to_message m, true end def jump_to_prev_and_open force_alignment=nil m = (0 .. curpos).to_a.reverse.argfind { |i| @message_lines[i] } return unless m nextm = @layout[m].prev return unless nextm if @layout[m].toggled_state == true @layout[m].state = :closed @layout[m].toggled_state = false update end if @layout[nextm].state == :closed @layout[nextm].state = :open @layout[nextm].toggled_state = true end jump_to_message nextm if nextm update if @layout[nextm].toggled_state end def jump_to_prev_open m = (0 .. curpos).to_a.reverse.argfind { |i| @message_lines[i] } # bah, .to_a return unless m ## jump to the top of the current message if we're in the body; ## otherwise, to the previous message top = @layout[m].top if curpos == top while(prevm = @layout[m].prev) break if @layout[prevm].state != :closed m = prevm end jump_to_message prevm if prevm else jump_to_message m end end def jump_to_message m, force_alignment=false l = @layout[m] ## boundaries of the message message_left = l.depth * @indent_spaces message_right = message_left + l.width ## calculate leftmost colum left = if force_alignment # force mode: align exactly message_left else # regular: minimize cursor movement ## leftmost and rightmost are boundaries of all valid left-column ## alignments. leftmost = [message_left, message_right - buffer.content_width + 1].min rightmost = message_left leftcol.clamp(leftmost, rightmost) end jump_to_line l.top # move vertically jump_to_col left # move horizontally set_cursor_pos l.top # set cursor pos end def expand_all_messages @global_message_state ||= :closed @global_message_state = (@global_message_state == :closed ? :open : :closed) @layout.each { |m, l| l.state = @global_message_state } update end def collapse_non_new_messages @layout.each { |m, l| l.state = l.orig_new ? :open : :closed } update end def expand_all_quotes if(m = @message_lines[curpos]) quotes = m.chunks.select { |c| (c.is_a?(Chunk::Quote) || c.is_a?(Chunk::Signature)) && c.lines.length > 1 } numopen = quotes.inject(0) { |s, c| s + (@chunk_layout[c].state == :open ? 1 : 0) } newstate = numopen > quotes.length / 2 ? :closed : :open quotes.each { |c| @chunk_layout[c].state = newstate } update end end def cleanup @layout = @chunk_layout = @text = nil # for good luck end def archive_and_kill; archive_and_then :kill end def spam_and_kill; spam_and_then :kill end def delete_and_kill; delete_and_then :kill end def kill_and_kill; kill_and_then :kill end def unread_and_kill; unread_and_then :kill end def do_nothing_and_kill; do_nothing_and_then :kill end def archive_and_next; archive_and_then :next end def spam_and_next; spam_and_then :next end def delete_and_next; delete_and_then :next end def kill_and_next; kill_and_then :next end def unread_and_next; unread_and_then :next end def do_nothing_and_next; do_nothing_and_then :next end def archive_and_prev; archive_and_then :prev end def spam_and_prev; spam_and_then :prev end def delete_and_prev; delete_and_then :prev end def kill_and_prev; kill_and_then :prev end def unread_and_prev; unread_and_then :prev end def do_nothing_and_prev; do_nothing_and_then :prev end def archive_and_then op dispatch op do @thread.remove_label :inbox UpdateManager.relay self, :archived, @thread.first Index.save_thread @thread UndoManager.register "archiving 1 thread" do @thread.apply_label :inbox Index.save_thread @thread UpdateManager.relay self, :unarchived, @thread.first end end end def spam_and_then op dispatch op do @thread.apply_label :spam UpdateManager.relay self, :spammed, @thread.first Index.save_thread @thread UndoManager.register "marking 1 thread as spam" do @thread.remove_label :spam Index.save_thread @thread UpdateManager.relay self, :unspammed, @thread.first end end end def delete_and_then op dispatch op do @thread.apply_label :deleted UpdateManager.relay self, :deleted, @thread.first Index.save_thread @thread UndoManager.register "deleting 1 thread" do @thread.remove_label :deleted Index.save_thread @thread UpdateManager.relay self, :undeleted, @thread.first end end end def kill_and_then op dispatch op do @thread.apply_label :killed UpdateManager.relay self, :killed, @thread.first Index.save_thread @thread UndoManager.register "killed 1 thread" do @thread.remove_label :killed Index.save_thread @thread UpdateManager.relay self, :unkilled, @thread.first end end end def unread_and_then op dispatch op do @thread.apply_label :unread UpdateManager.relay self, :unread, @thread.first Index.save_thread @thread end end def do_nothing_and_then op dispatch op end def dispatch op return if @dying @dying = true l = lambda do yield if block_given? BufferManager.kill_buffer_safely buffer end case op when :next @index_mode.launch_next_thread_after @thread, &l when :prev @index_mode.launch_prev_thread_before @thread, &l when :kill l.call else raise ArgumentError, "unknown thread dispatch operation #{op.inspect}" end end private :dispatch def pipe_message chunk = @chunk_lines[curpos] chunk = nil unless chunk.is_a?(Chunk::Attachment) message = @message_lines[curpos] unless chunk return unless chunk || message command = BufferManager.ask(:shell, "pipe command: ") return if command.nil? || command.empty? output, success = pipe_to_process(command) do |stream| if chunk stream.print chunk.raw_content else message.each_raw_message_line { |l| stream.print l } end end unless success BufferManager.flash "Invalid command: '#{command}' is not an executable" return end if output BufferManager.spawn "Output of '#{command}'", TextMode.new(output.ascii) else BufferManager.flash "'#{command}' done!" end end def status user_labels = @thread.labels.to_a.map do |l| l.to_s if LabelManager.user_defined_labels.member?(l) end.compact.join(",") user_labels = (user_labels.empty? and "" or "<#{user_labels}>") [user_labels, super].join(" -- ") end def goto_uri unless (chunk = @chunk_lines[curpos]) BufferManager.flash "No URI found." return end unless HookManager.enabled? "goto" BufferManager.flash "You must add a goto.rb hook before you can goto a URI." return end # @text is a list of lines with this format: # [ # [[:text_color, "Some text"]] # [[:text_color, " continued here"]] # ] linetext = @text.slice(curpos, @text.length).flatten(1) .take_while{|d| [:text_color, :sig_color].include?(d[0]) and d[1].strip != ""} # Only take up to the first "" alone on its line .map{|d| d[1].strip}.join("").strip found = false URI.extract(linetext || "").each do |match| begin u = URI.parse(match) next unless u.absolute? next unless ["http", "https"].include?(u.scheme) reallink = Shellwords.escape(u.to_s) BufferManager.flash "Going to #{reallink} ..." HookManager.run "goto", :uri => reallink BufferManager.completely_redraw_screen found = true rescue URI::InvalidURIError => e debug "not a uri: #{e}" # Do nothing, this is an ok flow end end BufferManager.flash "No URI found." unless found end def fetch_and_verify message = @message_lines[curpos] crypto_chunk = message.chunks.select {|chunk| chunk.is_a?(Chunk::CryptoNotice)}.first return unless crypto_chunk return unless crypto_chunk.unknown_fingerprint BufferManager.flash "Retrieving key #{crypto_chunk.unknown_fingerprint} ..." error = CryptoManager.retrieve crypto_chunk.unknown_fingerprint if error BufferManager.flash "Couldn't retrieve key: #{error.to_s}" else BufferManager.flash "Key #{crypto_chunk.unknown_fingerprint} successfully retrieved !" end # Re-trigger gpg verification message.reload_from_source! update end private def initial_state_for m if m.has_label?(:starred) || m.has_label?(:unread) :open else :closed end end def update regen_text buffer.mark_dirty if buffer end ## here we generate the actual content lines. we accumulate ## everything into @text, and we set @chunk_lines and ## @message_lines, and we update @layout. def regen_text @text = [] @chunk_lines = [] @message_lines = [] @person_lines = [] prevm = nil @thread.each do |m, depth, parent| unless m.is_a? Message # handle nil and :fake_root @text += chunk_to_lines m, nil, @text.length, depth, parent next end l = @layout[m] ## is this still necessary? next unless @layout[m].state # skip discarded drafts ## build the patina text = chunk_to_lines m, l.state, @text.length, depth, parent, l.color, l.star_color l.top = @text.length l.bot = @text.length + text.length # updated below l.prev = prevm l.next = nil l.depth = depth # l.state we preserve l.width = 0 # updated below @layout[l.prev].next = m if l.prev (0 ... text.length).each do |i| @chunk_lines[@text.length + i] = m @message_lines[@text.length + i] = m end @text += text prevm = m if l.state != :closed m.chunks.each do |c| cl = @chunk_layout[c] ## set the default state for chunks cl.state ||= if c.expandable? && c.respond_to?(:initial_state) c.initial_state else :closed end text = chunk_to_lines c, cl.state, @text.length, depth (0 ... text.length).each do |i| @chunk_lines[@text.length + i] = c @message_lines[@text.length + i] = m lw = text[i].flatten.select { |x| x.is_a? String }.map { |x| x.display_length }.sum - (depth * @indent_spaces) l.width = lw if lw > l.width end @text += text end @layout[m].bot = @text.length end end end def message_patina_lines m, state, start, parent, prefix, color, star_color prefix_widget = [color, prefix] open_widget = [color, (state == :closed ? "+ " : "- ")] new_widget = [color, (m.has_label?(:unread) ? "N" : " ")] starred_widget = if m.has_label?(:starred) [star_color, "*"] else [color, " "] end attach_widget = [color, (m.has_label?(:attachment) ? "@" : " ")] case state when :open @person_lines[start] = m.from [[prefix_widget, open_widget, new_widget, attach_widget, starred_widget, [color, "#{m.from ? m.from.mediumname.fix_encoding! : '?'} to #{m.recipients.map { |l| l.shortname.fix_encoding! }.join(', ')} #{m.date.to_nice_s.fix_encoding!} (#{m.date.to_nice_distance_s.fix_encoding!})"]]] when :closed @person_lines[start] = m.from [[prefix_widget, open_widget, new_widget, attach_widget, starred_widget, [color, "#{m.from ? m.from.mediumname.fix_encoding! : '?'}, #{m.date.to_nice_s.fix_encoding!} (#{m.date.to_nice_distance_s.fix_encoding!}) #{m.snippet ? m.snippet.fix_encoding! : ''}"]]] when :detailed @person_lines[start] = m.from from_line = [[prefix_widget, open_widget, new_widget, attach_widget, starred_widget, [color, "From: #{m.from ? format_person(m.from) : '?'}"]]] addressee_lines = [] unless m.to.empty? m.to.each_with_index { |p, i| @person_lines[start + addressee_lines.length + from_line.length + i] = p } addressee_lines += format_person_list " To: ", m.to end unless m.cc.empty? m.cc.each_with_index { |p, i| @person_lines[start + addressee_lines.length + from_line.length + i] = p } addressee_lines += format_person_list " Cc: ", m.cc end unless m.bcc.empty? m.bcc.each_with_index { |p, i| @person_lines[start + addressee_lines.length + from_line.length + i] = p } addressee_lines += format_person_list " Bcc: ", m.bcc end headers = { "Date" => "#{m.date.to_message_nice_s} (#{m.date.to_nice_distance_s})", "Subject" => m.subj } show_labels = @thread.labels - LabelManager::HIDDEN_RESERVED_LABELS unless show_labels.empty? headers["Labels"] = show_labels.map { |x| x.to_s }.sort.join(', ') end if parent headers["In reply to"] = "#{parent.from.mediumname}'s message of #{parent.date.to_message_nice_s}" end HookManager.run "detailed-headers", :message => m, :headers => headers from_line + (addressee_lines + headers.map { |k, v| " #{k}: #{v}" }).map { |l| [[color, prefix + " " + l]] } end end def format_person_list prefix, people ptext = people.map { |p| format_person p } pad = " " * prefix.display_length [prefix + ptext.first + (ptext.length > 1 ? "," : "")] + ptext[1 .. -1].map_with_index do |e, i| pad + e + (i == ptext.length - 1 ? "" : ",") end end def format_person p p.longname + (ContactManager.is_aliased_contact?(p) ? " (#{ContactManager.alias_for p})" : "") end def maybe_wrap_text lines if @wrap config_width = $config[:wrap_width] if config_width and config_width != 0 width = [config_width, buffer.content_width].min else width = buffer.content_width end # lines can apparently be both String and Array, convert to Array for map. if lines.kind_of? String lines = lines.lines.to_a end lines = lines.map { |l| l.chomp.wrap width if l }.flatten end return lines end ## todo: check arguments on this overly complex function def chunk_to_lines chunk, state, start, depth, parent=nil, color=nil, star_color=nil prefix = " " * @indent_spaces * depth case chunk when :fake_root [[[:missing_message_color, "#{prefix}"]]] when nil [[[:missing_message_color, "#{prefix}"]]] when Message message_patina_lines(chunk, state, start, parent, prefix, color, star_color) + (chunk.is_draft? ? [[[:draft_notification_color, prefix + " >>> This message is a draft. Hit 'e' to edit, 'y' to send. <<<"]]] : []) else raise "Bad chunk: #{chunk.inspect}" unless chunk.respond_to?(:inlineable?) ## debugging if chunk.inlineable? lines = maybe_wrap_text(chunk.lines) lines.map { |line| [[chunk.color, "#{prefix}#{line}"]] } elsif chunk.expandable? case state when :closed [[[chunk.patina_color, "#{prefix}+ #{chunk.patina_text}"]]] when :open lines = maybe_wrap_text(chunk.lines) [[[chunk.patina_color, "#{prefix}- #{chunk.patina_text}"]]] + lines.map { |line| [[chunk.color, "#{prefix}#{line}"]] } end else [[[chunk.patina_color, "#{prefix}x #{chunk.patina_text}"]]] end end end def view chunk BufferManager.flash "viewing #{chunk.content_type} attachment..." success = chunk.view! BufferManager.erase_flash BufferManager.completely_redraw_screen unless success BufferManager.spawn "Attachment: #{chunk.filename}", TextMode.new(chunk.to_s.ascii, chunk.filename) BufferManager.flash "Couldn't execute view command, viewing as text." end end end end sup-1.1/lib/sup/modes/search_results_mode.rb0000644000004100000410000000347414246427237021300 0ustar www-datawww-datamodule Redwood class SearchResultsMode < ThreadIndexMode def initialize query @query = query super [], query end register_keymap do |k| k.add :refine_search, "Refine search", '|' k.add :save_search, "Save search", '%' end def refine_search text = BufferManager.ask :search, "refine query: ", (@query[:text] + " ") return unless text && text !~ /^\s*$/ SearchResultsMode.spawn_from_query text end def save_search name = BufferManager.ask :save_search, "Name this search: " return unless name && name !~ /^\s*$/ name.strip! unless SearchManager.valid_name? name BufferManager.flash "Not saved: " + SearchManager.name_format_hint return end if SearchManager.all_searches.include? name BufferManager.flash "Not saved: \"#{name}\" already exists" return end BufferManager.flash "Search saved as \"#{name}\"" if SearchManager.add name, @query[:text].strip end ## a proper is_relevant? method requires some way of asking the index ## if an in-memory object satisfies a query. i'm not sure how to do ## that yet. in the worst case i can make an in-memory index, add ## the message, and search against it to see if i have > 0 results, ## but that seems pretty insane. def self.spawn_from_query text begin if SearchManager.predefined_queries.has_key? text query = SearchManager.predefined_queries[text] else query = Index.parse_query(text) end return unless query short_text = text.length < 20 ? text : text[0 ... 20] + "..." mode = SearchResultsMode.new query BufferManager.spawn "search: \"#{short_text}\"", mode mode.load_threads :num => mode.buffer.content_height rescue Index::ParseError => e BufferManager.flash "Problem: #{e.message}!" end end end end sup-1.1/lib/sup/modes/person_search_results_mode.rb0000644000004100000410000000034414246427237022657 0ustar www-datawww-datamodule Redwood class PersonSearchResultsMode < ThreadIndexMode def initialize people @people = people super [], { :participants => @people } end def is_relevant? m; @people.any? { |p| m.from == p }; end end end sup-1.1/lib/sup/modes/inbox_mode.rb0000644000004100000410000000412514246427237017363 0ustar www-datawww-datarequire "sup/modes/thread_index_mode" module Redwood class InboxMode < ThreadIndexMode register_keymap do |k| ## overwrite toggle_archived with archive k.add :archive, "Archive thread (remove from inbox)", 'a' k.add :refine_search, "Refine search", '|' end def initialize super [:inbox, :sent, :draft], { :label => :inbox, :skip_killed => true } raise "can't have more than one!" if defined? @@instance @@instance = self end def is_relevant? m; (m.labels & [:spam, :deleted, :killed, :inbox]) == Set.new([:inbox]) end def refine_search text = BufferManager.ask :search, "refine inbox with query: " return unless text && text !~ /^\s*$/ text = "label:inbox -label:spam -label:deleted " + text SearchResultsMode.spawn_from_query text end ## label-list-mode wants to be able to raise us if the user selects ## the "inbox" label, so we need to keep our singletonness around def self.instance; @@instance; end def killable?; false; end def archive return unless cursor_thread thread = cursor_thread # to make sure lambda only knows about 'old' cursor_thread UndoManager.register "archiving thread" do thread.apply_label :inbox add_or_unhide thread.first Index.save_thread thread end cursor_thread.remove_label :inbox hide_thread cursor_thread regen_text Index.save_thread thread end def multi_archive threads UndoManager.register "archiving #{threads.size.pluralize 'thread'}" do threads.map do |t| t.apply_label :inbox add_or_unhide t.first Index.save_thread t end regen_text end threads.each do |t| t.remove_label :inbox hide_thread t end regen_text threads.each { |t| Index.save_thread t } end def handle_unarchived_update sender, m add_or_unhide m end def handle_archived_update sender, m t = thread_containing(m) or return hide_thread t regen_text end def handle_idle_update sender, idle_since flush_index end def status super + " #{Index.size} messages in index" end end end sup-1.1/lib/sup/modes/line_cursor_mode.rb0000644000004100000410000001161014246427237020565 0ustar www-datawww-datamodule Redwood ## extends ScrollMode to have a line-based cursor. class LineCursorMode < ScrollMode register_keymap do |k| ## overwrite scrollmode binding on arrow keys for cursor movement ## but j and k still scroll! k.add :cursor_down, "Move cursor down one line", :down, 'j' k.add :cursor_up, "Move cursor up one line", :up, 'k' k.add :select, "Select this item", :enter end attr_reader :curpos def initialize opts={} @cursor_top = @curpos = opts.delete(:skip_top_rows) || 0 @load_more_callbacks = [] @load_more_q = Queue.new @load_more_thread = ::Thread.new do while true e = @load_more_q.pop @load_more_callbacks.each { |c| c.call e } sleep 0.5 @load_more_q.pop until @load_more_q.empty? end end super opts end def cleanup @load_more_thread.kill super end def draw super set_status end protected ## callbacks when the cursor is asked to go beyond the bottom def to_load_more &b @load_more_callbacks << b end def draw_line ln, opts={} if ln == @curpos super ln, :highlight => true, :debug => opts[:debug], :color => :text_color else super ln, :color => :text_color end end def ensure_mode_validity super raise @curpos.inspect unless @curpos.is_a?(Integer) c = @curpos.clamp topline, botline - 1 c = @cursor_top if c < @cursor_top buffer.mark_dirty unless c == @curpos @curpos = c end def set_cursor_pos p return if @curpos == p @curpos = p.clamp @cursor_top, lines buffer.mark_dirty if buffer # not sure why the buffer is gone set_status end ## override search behavior to be cursor-based. this is a stupid ## implementation and should be made better. TODO: improve. def search_goto_line line page_down while line >= botline page_up while line < topline set_cursor_pos line end def search_start_line; @curpos end def line_down # overwrite scrollmode super call_load_more_callbacks([topline + buffer.content_height - lines, 10].max) if topline + buffer.content_height > lines set_cursor_pos topline if @curpos < topline end def line_up # overwrite scrollmode super set_cursor_pos botline - 1 if @curpos > botline - 1 end def cursor_down call_load_more_callbacks buffer.content_height if @curpos >= lines - [buffer.content_height/2,1].max return false unless @curpos < lines - 1 if $config[:continuous_scroll] and (@curpos == botline - 3 and @curpos < lines - 3) # load more lines, one at a time. jump_to_line topline + 1 @curpos += 1 unless buffer.dirty? draw_line @curpos - 1 draw_line @curpos set_status buffer.commit end elsif @curpos >= botline - 1 page_down set_cursor_pos topline else @curpos += 1 unless buffer.dirty? draw_line @curpos - 1 draw_line @curpos set_status buffer.commit end end true end def cursor_up return false unless @curpos > @cursor_top if $config[:continuous_scroll] and (@curpos == topline + 2) jump_to_line topline - 1 @curpos -= 1 unless buffer.dirty? draw_line @curpos + 1 draw_line @curpos set_status buffer.commit end elsif @curpos == topline old_topline = topline page_up set_cursor_pos [old_topline - 1, topline].max else @curpos -= 1 unless buffer.dirty? draw_line @curpos + 1 draw_line @curpos set_status buffer.commit end end true end def page_up # overwrite if topline <= @cursor_top set_cursor_pos @cursor_top else relpos = @curpos - topline super set_cursor_pos topline + relpos end end ## more complicated than one might think. three behaviors. def page_down ## if we're on the last page, and it's not a full page, just move ## the cursor down to the bottom and assume we can't load anything ## else via the callbacks. if topline > lines - buffer.content_height set_cursor_pos(lines - 1) ## if we're on the last page, and it's a full page, try and load ## more lines via the callbacks and then shift the page down elsif topline == lines - buffer.content_height call_load_more_callbacks buffer.content_height super ## otherwise, just move down else relpos = @curpos - topline super set_cursor_pos [topline + relpos, lines - 1].min end end def jump_to_start super set_cursor_pos @cursor_top end def jump_to_end super if topline < (lines - buffer.content_height) set_cursor_pos(lines - 1) end private def set_status l = lines @status = l > 0 ? "line #{@curpos + 1} of #{l}" : "" end def call_load_more_callbacks size @load_more_q.push size if $config[:load_more_threads_when_scrolling] end end end sup-1.1/lib/sup/modes/poll_mode.rb0000644000004100000410000000045214246427237017211 0ustar www-datawww-datamodule Redwood class PollMode < LogMode def initialize @new = true super "poll for new messages" end def poll unless @new @new = false self << "\n" end self << "Poll started at #{Time.now}\n" PollManager.do_poll { |s| self << (s + "\n") } end end end sup-1.1/lib/sup/modes/console_mode.rb0000644000004100000410000000536114246427237017711 0ustar www-datawww-datarequire 'pp' require "sup/service/label_service" module Redwood class Console def initialize mode @mode = mode @label_service = LabelService.new end def query(query) Enumerator.new(Index.instance, :each_message, Index.parse_query(query)) end def add_labels(query, *labels) count = @label_service.add_labels(query, *labels) print_buffer_dirty_msg count end def remove_labels(query, *labels) count = @label_service.remove_labels(query, *labels) print_buffer_dirty_msg count end def print_buffer_dirty_msg msg_count puts "Scanned #{msg_count} messages." puts "You might want to refresh open buffers with `@` key." end private :print_buffer_dirty_msg def xapian; Index.instance.instance_variable_get :@xapian; end def loglevel; Redwood::Logger.level; end def set_loglevel(level); Redwood::Logger.level = level; end def special_methods; public_methods - Object.methods end def puts x; @mode << "#{x.to_s.rstrip}\n" end def p x; puts x.inspect end ## files that won't cause problems when reloaded ## TODO expand this list / convert to blacklist RELOAD_WHITELIST = %w(sup/index.rb sup/modes/console-mode.rb) def reload old_verbose = $VERBOSE $VERBOSE = nil old_features = $".dup begin fs = $".grep(/^sup\//) fs.reject! { |f| not RELOAD_WHITELIST.member? f } fs.each { |f| $".delete f } fs.each do |f| @mode << "reloading #{f}\n" begin require f rescue LoadError => e raise unless e.message =~ /no such file to load/ end end rescue Exception $".clear $".concat old_features raise ensure $VERBOSE = old_verbose end true end def clear_hooks HookManager.clear nil end end class ConsoleMode < LogMode register_keymap do |k| k.add :run, "Restart evaluation", 'e' end def initialize super "console" @console = Console.new self @binding = @console.instance_eval { binding } end def execute cmd begin self << ">> #{cmd}\n" ret = eval cmd, @binding self << "=> #{ret.pretty_inspect}\n" rescue Exception self << "#{$!.class}: #{$!.message}\n" clean_backtrace = [] $!.backtrace.each { |l| break if l =~ /console-mode/; clean_backtrace << l } clean_backtrace.each { |l| self << "#{l}\n" } end end def prompt BufferManager.ask :console, ">> " end def run self << <= @level send_message format_message(l, Time.now, s) end end end ## send a message regardless of the current logging level def force_message m; send_message format_message(nil, Time.now, m) end private ## level can be nil! def format_message level, time, msg prefix = case level when "warn"; "WARNING: " when "error"; "ERROR: " else "" end "[#{time.to_s}] #{prefix}#{msg.rstrip}\n" end ## actually distribute the message def send_message m @mutex.synchronize do @sinks.each do |sink| sink << m sink.flush if sink.respond_to?(:flush) and level == "debug" end @buf << m end end end ## include me to have top-level #debug, #info, etc. methods. module LogsStuff Logger::LEVELS.each { |l| define_method(l) { |s, uplevel = 0| Logger.instance.send(l, s) } } end end sup-1.1/lib/sup/util.rb0000644000004100000410000003777114246427237015123 0ustar www-datawww-data# encoding: utf-8 require 'thread' require 'lockfile' require 'mime/types' require 'pathname' require 'rmail' require 'set' require 'enumerator' require 'benchmark' require 'unicode' require 'unicode/display_width' require 'fileutils' module ExtendedLockfile def gen_lock_id Hash[ 'host' => "#{ Socket.gethostname }", 'pid' => "#{ Process.pid }", 'ppid' => "#{ Process.ppid }", 'time' => timestamp, 'pname' => $0, 'user' => ENV["USER"] ] end def dump_lock_id lock_id = @lock_id "host: %s\npid: %s\nppid: %s\ntime: %s\nuser: %s\npname: %s\n" % lock_id.values_at('host','pid','ppid','time','user', 'pname') end def lockinfo_on_disk h = load_lock_id IO.read(path) h['mtime'] = File.mtime path h['path'] = path h end def touch_yourself; touch path end end Lockfile.send :prepend, ExtendedLockfile class File # platform safe file.link which attempts a copy if hard-linking fails def self.safe_link src, dest begin File.link src, dest rescue FileUtils.copy src, dest end end end class Pathname def human_size s = begin size rescue SystemCallError return "?" end s.to_human_size end def human_time begin ctime.strftime("%Y-%m-%d %H:%M") rescue SystemCallError "?" end end end ## more monkeypatching! module RMail class EncodingUnsupportedError < StandardError; end class Message def self.make_file_attachment fn bfn = File.basename fn t = MIME::Types.type_for(bfn).first || MIME::Types.type_for("exe").first make_attachment IO.read(fn), t.content_type, t.encoding, bfn.to_s end def charset if header.field?("content-type") && header.fetch("content-type") =~ /charset\s*=\s*"?(.*?)"?(;|$)/i $1 end end def self.make_attachment payload, mime_type, encoding, filename a = Message.new a.header.add "Content-Disposition", "attachment; filename=#{filename.inspect}" a.header.add "Content-Type", "#{mime_type}; name=#{filename.inspect}" a.header.add "Content-Transfer-Encoding", encoding if encoding a.body = case encoding when "base64" [payload].pack "m" when "quoted-printable" [payload].pack "M" when "7bit", "8bit", nil payload else raise EncodingUnsupportedError, encoding.inspect end a end end module CustomizedSerialize ## Don't add MIME-Version headers on serialization. Sup sometimes want's to serialize ## message parts where these headers are not needed and messing with the message on ## serialization breaks gpg signatures. The commented section shows the original RMail ## code. def calculate_boundaries(message) calculate_boundaries_low(message, []) # unless message.header['MIME-Version'] # message.header['MIME-Version'] = "1.0" # end end end Serialize.send :prepend, CustomizedSerialize end class Module def bool_reader *args args.each { |sym| class_eval %{ def #{sym}?; @#{sym}; end } } end def bool_writer *args; attr_writer(*args); end def bool_accessor *args bool_reader(*args) bool_writer(*args) end def defer_all_other_method_calls_to obj class_eval %{ def method_missing meth, *a, &b; @#{obj}.send meth, *a, &b; end def respond_to?(m, include_private = false) @#{obj}.respond_to?(m, include_private) end } end end class Object ## "k combinator" def returning x; yield x; x; end unless method_defined? :tap def tap; yield self; self; end end ## clone of java-style whole-method synchronization ## assumes a @mutex variable ## TODO: clean up, try harder to avoid namespace collisions def synchronized *methods methods.each do |meth| class_eval <<-EOF alias unsynchronized_#{meth} #{meth} def #{meth}(*a, &b) @mutex.synchronize { unsynchronized_#{meth}(*a, &b) } end EOF end end def ignore_concurrent_calls *methods methods.each do |meth| mutex = "@__concurrent_protector_#{meth}" flag = "@__concurrent_flag_#{meth}" oldmeth = "__unprotected_#{meth}" class_eval <<-EOF alias #{oldmeth} #{meth} def #{meth}(*a, &b) #{mutex} = Mutex.new unless defined? #{mutex} #{flag} = true unless defined? #{flag} run = #{mutex}.synchronize do if #{flag} #{flag} = false true end end if run ret = #{oldmeth}(*a, &b) #{mutex}.synchronize { #{flag} = true } ret end end EOF end end def benchmark s, &b ret = nil times = Benchmark.measure { ret = b.call } debug "benchmark #{s}: #{times}" ret end end class String def display_length @display_length ||= Unicode::DisplayWidth.of(self) end def slice_by_display_length len each_char.each_with_object "" do |c, buffer| len -= Unicode::DisplayWidth.of(c) return buffer if len < 0 buffer << c end end def camel_to_hyphy self.gsub(/([a-z])([A-Z0-9])/, '\1-\2').downcase end def find_all_positions x ret = [] start = 0 while start < length pos = index x, start break if pos.nil? ret << pos start = pos + 1 end ret end ## a very complicated regex found on teh internets to split on ## commas, unless they occurr within double quotes. def split_on_commas normalize_whitespace().split(/,\s*(?=(?:[^"]*"[^"]*")*(?![^"]*"))/) end ## ok, here we do it the hard way. got to have a remainder for purposes of ## tab-completing full email addresses def split_on_commas_with_remainder ret = [] state = :outstring pos = 0 region_start = 0 while pos <= length newpos = case state when :escaped_instring, :escaped_outstring then pos else index(/[,"\\]/, pos) end if newpos char = self[newpos] else char = nil newpos = length end case char when ?" state = case state when :outstring then :instring when :instring then :outstring when :escaped_instring then :instring when :escaped_outstring then :outstring end when ?,, nil state = case state when :outstring, :escaped_outstring then ret << self[region_start ... newpos].gsub(/^\s+|\s+$/, "") region_start = newpos + 1 :outstring when :instring then :instring when :escaped_instring then :instring end when ?\\ state = case state when :instring then :escaped_instring when :outstring then :escaped_outstring when :escaped_instring then :instring when :escaped_outstring then :outstring end end pos = newpos + 1 end remainder = case state when :instring self[region_start .. -1].gsub(/^\s+/, "") else nil end [ret, remainder] end def wrap len ret = [] s = self while s.display_length > len slice = s.slice_by_display_length(len) cut = slice.rindex(/\s/) if cut ret << s[0 ... cut] s = s[(cut + 1) .. -1] else ret << slice s = s[slice.length .. -1] end end ret << s end # Fix the damn string! make sure it is valid utf-8, then convert to # user encoding. def fix_encoding! # first try to encode to utf-8 from whatever current encoding encode!('UTF-8', :invalid => :replace, :undef => :replace) # do this anyway in case string is set to be UTF-8, encoding to # something else (UTF-16 which can fully represent UTF-8) and back # ensures invalid chars are replaced. encode!('UTF-16', 'UTF-8', :invalid => :replace, :undef => :replace) encode!('UTF-8', 'UTF-16', :invalid => :replace, :undef => :replace) fail "Could not create valid UTF-8 string out of: '#{self.to_s}'." unless valid_encoding? # now convert to $encoding encode!($encoding, :invalid => :replace, :undef => :replace) fail "Could not create valid #{$encoding.inspect} string out of: '#{self.to_s}'." unless valid_encoding? self end # transcode the string if original encoding is know # fix if broken. def transcode to_encoding, from_encoding begin encode!(to_encoding, from_encoding, :invalid => :replace, :undef => :replace) unless valid_encoding? # fix encoding (through UTF-8) encode!('UTF-16', from_encoding, :invalid => :replace, :undef => :replace) encode!(to_encoding, 'UTF-16', :invalid => :replace, :undef => :replace) end rescue Encoding::ConverterNotFoundError debug "Encoding converter not found for #{from_encoding.inspect} or #{to_encoding.inspect}, fixing string: '#{self.to_s}', but expect weird characters." fix_encoding! end fail "Could not create valid #{to_encoding.inspect} string out of: '#{self.to_s}'." unless valid_encoding? self end ## Decodes UTF-7 and returns the resulting decoded string as UTF-8. ## ## Ruby doesn't supply a UTF-7 encoding natively. There is ## Net::IMAP::decode_utf7 which only handles the IMAP "modified UTF-7" ## encoding. This implementation is inspired by that one but handles ## standard UTF-7 shift characters and not the IMAP-specific variation. def decode_utf7 gsub(/\+([^-]+)?-/) { if $1 ($1 + "===").unpack("m")[0].encode(Encoding::UTF_8, Encoding::UTF_16BE) else "+" end } end def normalize_whitespace gsub(/\t/, " ").gsub(/\r/, "") end unless method_defined? :ord def ord self[0] end end unless method_defined? :each def each &b each_line(&b) end end ## takes a list of words, and returns an array of symbols. typically used in ## Sup for translating Xapian's representation of a list of labels (a string) ## to an array of label symbols. ## ## split_on will be passed to String#split, so you can leave this nil for space. def to_set_of_symbols split_on=nil; Set.new split(split_on).map { |x| x.strip.intern } end class CheckError < ArgumentError; end def check begin fail "unexpected encoding #{encoding}" if respond_to?(:encoding) && !(encoding == Encoding::UTF_8 || encoding == Encoding::ASCII) fail "invalid encoding" if respond_to?(:valid_encoding?) && !valid_encoding? rescue raise CheckError.new($!.message) end end def ascii out = "" each_byte do |b| if (b & 128) != 0 out << "\\x#{b.to_s 16}" else out << b.chr end end out = out.fix_encoding! # this should now be an utf-8 string of ascii # compat chars. end end class Numeric def clamp min, max if self < min min elsif self > max max else self end end def in? range; range.member? self; end def to_human_size if self < 1024 to_s + "B" elsif self < (1024 * 1024) (self / 1024).to_s + "KiB" elsif self < (1024 * 1024 * 1024) (self / 1024 / 1024).to_s + "MiB" else (self / 1024 / 1024 / 1024).to_s + "GiB" end end end class Integer def to_character if self < 128 && self >= 0 chr else "<#{self}>" end end ## hacking the english language def pluralize s to_s + " " + if self == 1 s else if s =~ /(.*)y$/ $1 + "ies" else s + "s" end end end end class Hash def - o Hash[*self.map { |k, v| [k, v] unless o.include? k }.compact.flatten_one_level] end def select_by_value v=true select { |k, vv| vv == v }.map { |x| x.first } end end module Enumerable def map_with_index ret = [] each_with_index { |x, i| ret << yield(x, i) } ret end if not method_defined? :sum def sum; inject(0) { |x, y| x + y }; end end def map_to_hash ret = {} each { |x| ret[x] = yield(x) } ret end # like find, except returns the value of the block rather than the # element itself. def argfind ret = nil find { |e| ret ||= yield(e) } ret || nil # force end def argmin best, bestval = nil, nil each do |e| val = yield e if bestval.nil? || val < bestval best, bestval = e, val end end best end ## returns the maximum shared prefix of an array of strings ## optinally excluding a prefix def shared_prefix caseless=false, exclude="" return "" if empty? prefix = "" (0 ... first.length).each do |i| c = (caseless ? first.downcase : first)[i] break unless all? { |s| (caseless ? s.downcase : s)[i] == c } next if exclude[i] == c prefix += first[i].chr end prefix end def max_of map { |e| yield e }.max end ## returns all the entries which are equal to startline up to endline def between startline, endline select { |l| true if l == startline .. l == endline } end end class Array def flatten_one_level inject([]) { |a, e| a + e } end if not method_defined? :to_h def to_h; Hash[*flatten_one_level]; end end def rest; self[1..-1]; end def to_boolean_h; Hash[*map { |x| [x, true] }.flatten]; end def last= e; self[-1] = e end def nonempty?; !empty? end end ## simple singleton module. far less complete and insane than the ruby standard ## library one, but it automatically forwards methods calls and allows for ## constructors that take arguments. ## ## classes that inherit this can define initialize. however, you cannot call ## .new on the class. To get the instance of the class, call .instance; ## to create the instance, call init. module Redwood module Singleton module ClassMethods def instance; @instance; end def instantiated?; defined?(@instance) && !@instance.nil?; end def deinstantiate!; @instance = nil; end def method_missing meth, *a, &b raise "no #{name} instance defined in method call to #{meth}!" unless defined? @instance ## if we've been deinstantiated, just drop all calls. this is ## useful because threads that might be active during the ## cleanup process (e.g. polling) would otherwise have to ## special-case every call to a Singleton object return nil if @instance.nil? # Speed up further calls by defining a shortcut around method_missing if meth.to_s[-1,1] == '=' # Argh! Inconsistency! Setters do not work like all the other methods. class_eval "def self.#{meth}(a); @instance.send :#{meth}, a; end" else class_eval "def self.#{meth}(*a, &b); @instance.send :#{meth}, *a, &b; end" end @instance.send meth, *a, &b end def init *args raise "there can be only one! (instance)" if instantiated? @instance = new(*args) end end def self.included klass klass.private_class_method :allocate, :new klass.extend ClassMethods end end end ## acts like a hash with an initialization block, but saves any ## newly-created value even upon lookup. ## ## for example: ## ## class C ## attr_accessor :val ## def initialize; @val = 0 end ## end ## ## h = Hash.new { C.new } ## h[:a].val # => 0 ## h[:a].val = 1 ## h[:a].val # => 0 ## ## h2 = SavingHash.new { C.new } ## h2[:a].val # => 0 ## h2[:a].val = 1 ## h2[:a].val # => 1 ## ## important note: you REALLY want to use #member? to test existence, ## because just checking h[anything] will always evaluate to true ## (except for degenerate constructor blocks that return nil or false) class SavingHash def initialize &b @constructor = b @hash = Hash.new end def [] k @hash[k] ||= @constructor.call(k) end defer_all_other_method_calls_to :hash end ## easy thread-safe class for determining who's the "winner" in a race (i.e. ## first person to hit the finish line class FinishLine def initialize @m = Mutex.new @over = false end def winner? @m.synchronize { !@over && @over = true } end end sup-1.1/lib/sup/update.rb0000644000004100000410000000153214246427237015412 0ustar www-datawww-datamodule Redwood ## Classic listener/broadcaster paradigm. Handles communication between various ## parts of Sup. ## ## Usage note: don't pass threads around. Neither thread nor message equality is ## defined anywhere in Sup beyond standard object equality. To communicate ## something about a particular thread, just pass a representative message from ## it around. ## ## (This assumes that no message will be a part of more than one thread within a ## single "view". Luckily, that's true.) class UpdateManager include Redwood::Singleton def initialize @targets = {} end def register o; @targets[o] = true; end def unregister o; @targets.delete o; end def relay sender, type, *args meth = "handle_#{type}_update".intern @targets.keys.each { |o| o.send meth, sender, *args unless o == sender if o.respond_to? meth } end end end sup-1.1/lib/sup/search.rb0000644000004100000410000000625714246427237015406 0ustar www-datawww-data# encoding: utf-8 module Redwood class SearchManager include Redwood::Singleton class ExpansionError < StandardError; end attr_reader :predefined_searches def initialize fn @fn = fn @searches = {} if File.exist? fn IO.foreach(fn) do |l| l =~ /^([^:]*): (.*)$/ or raise "can't parse #{fn} line #{l.inspect}" @searches[$1] = $2 end end @modified = false @predefined_searches = { 'All mail' => 'Search all mail.' } @predefined_queries = { 'All mail'.to_sym => { :qobj => Xapian::Query.new('Kmail'), :load_spam => false, :load_deleted => false, :load_killed => false, :text => 'Search all mail.'} } @predefined_searches.each do |k,v| @searches[k] = v end end def predefined_queries; return @predefined_queries; end def all_searches; return @searches.keys.sort; end def search_string_for name; if @predefined_searches.keys.member? name return name.to_sym end return @searches[name]; end def valid_name? name; name =~ /^[\w-]+$/; end def name_format_hint; "letters, numbers, underscores and dashes only"; end def add name, search_string return unless valid_name? name if @predefined_searches.has_key? name warn "cannot add search: #{name} is already taken by a predefined search" return end @searches[name] = search_string @modified = true end def rename old, new return unless @searches.has_key? old if [old, new].any? { |x| @predefined_searches.has_key? x } warn "cannot rename search: #{old} or #{new} is already taken by a predefined search" return end search_string = @searches[old] delete old if add new, search_string end def edit name, search_string return unless @searches.has_key? name if @predefined_searches.has_key? name warn "cannot edit predefined search: #{name}." return end @searches[name] = search_string @modified = true end def delete name return unless @searches.has_key? name if @predefined_searches.has_key? name warn "cannot delete predefined search: #{name}." return end @searches.delete name @modified = true end def expand search_string expanded = search_string.dup until (matches = expanded.scan(/\{([\w-]+)\}/).flatten).empty? if !(unknown = matches - @searches.keys).empty? error_message = "Unknown \"#{unknown.join('", "')}\" when expanding \"#{search_string}\"" elsif expanded.size >= 2048 error_message = "Check for infinite recursion in \"#{search_string}\"" end if error_message warn error_message raise ExpansionError, error_message end matches.each { |n| expanded.gsub! "{#{n}}", "(#{@searches[n]})" if @searches.has_key? n } end return expanded end def save return unless @modified File.open(@fn, "w:UTF-8") { |f| (@searches - @predefined_searches.keys).sort.each { |(n, s)| f.puts "#{n}: #{s}" } } @modified = false end end end sup-1.1/lib/sup/logger/0000755000004100000410000000000014246427237015061 5ustar www-datawww-datasup-1.1/lib/sup/logger/singleton.rb0000644000004100000410000000046414246427237017414 0ustar www-datawww-data# TODO: this is ugly. It's better to have a application singleton passed # down to lower level components instead of including logging methods in # class `Object' # # For now this is what we have to do. require "sup/logger" Redwood::Logger.init.add_sink $stderr class Object include Redwood::LogsStuff end sup-1.1/lib/sup/undo.rb0000644000004100000410000000160514246427237015076 0ustar www-datawww-datamodule Redwood ## Implements a single undo list for the Sup instance ## ## The basic idea is to keep a list of lambdas to undo ## things. When an action is called (such as 'archive'), ## a lambda is registered with UndoManager that will ## undo the archival action class UndoManager include Redwood::Singleton def initialize @@actionlist = [] end def register desc, *actions, &b actions = [*actions.flatten] actions << b if b raise ArgumentError, "need at least one action" unless actions.length > 0 @@actionlist.push :desc => desc, :actions => actions end def undo unless @@actionlist.empty? actionset = @@actionlist.pop actionset[:actions].each { |action| action.call } BufferManager.flash "undid #{actionset[:desc]}" else BufferManager.flash "nothing more to undo!" end end def clear @@actionlist = [] end end end sup-1.1/doc/0000755000004100000410000000000014246427237012772 5ustar www-datawww-datasup-1.1/doc/FAQ.txt0000644000004100000410000001131614246427237014144 0ustar www-datawww-dataSup FAQ ------- Q: What is Sup? A: A console-based email client for people with a lot of email. Q: What does Sup stand for? A: "What's up?" Q: Sup looks like a text-based Gmail. A: First I stole their ideas. Then I improved them. Q: Why not just use Gmail? A: I hate ads, I hate using a mouse, and I hate non-programmability and non-extensibility. Also, Gmail doesn't let you use a monospace font, which is just lame. Also, Gmail encourages top-posting. THIS CANNOT BE TOLERATED! Q: Why the console? A: Because a keystroke is worth a hundred mouse clicks, as any Unix user knows. Because you don't need a web browser. Because you get an instantaneous response and a simple interface. Q: How does Sup deal with spam? A: You can manually mark messages as spam, which prevents them from showing up in future searches. Later, you can run a batch process to remove such messages from your sources. That's as far as Sup goes. Spam filtering should be done by a dedicated tool like SpamAssassin. Q: How do I delete a message? A: Why delete? Unless it's spam, you might as well just archive it. Q: C'mon, really now! A: Ok, press the 'd' key. Q: But I want to delete it for real, not just add a 'deleted' flag in the index. I want it gone from disk! A: Currently, for mbox sources, there is a batch deletion tool that will strip out all messages marked as spam or deleted. Q: How well does Sup play with other mail clients? A: Not well at all. If messages have been moved, deleted, or altered due to some other client, Sup will have to rebuild its index for that message source. For example, for mbox files, reading a single unread message changes the offsets of every file on disk. Rather than rescanning every time, Sup assumes sources don't change except by having new messages added. If that assumption is violated, you'll have to sync the index. Q: How do I back up my index? A: Since the contents of the messages are recoverable from their sources using sup-sync, all you need to back up is the message state. To do this, simply run: sup-dump > This will save all message state in a big text file, which you should probably compress. Q: How do I restore the message state I saved in my state dump? A: Run: sup-sync [+] --restored --restore where was created as above. Q: Xapian crashed and I can't read my index. Luckily I made a state dump. What should I do? Q: How do I rebuild the index completely? A: Run: rm -rf ~/.sup/xapian # omg wtf sup-sync --all-sources --all --restore Voila! A brand new index. Q: I want to move messages from one source to another. (E.g., my primary inbox is an mbox file, and I want to move some of those messages to a Maildir.) How do I do that while preserving message state? A: Move the messages from the source to the target using whatever tool you'd like. Mutt's a good one. :) Then run: sup-sync --changed Note that if you sup-sync only one source at a time, depending on the order in which you do it, the messages may be treated as missing and then deleted from the index, which means that their states will be lost when you sync the other source. So do them both in one go. Q: What are all these "Redwood" references I see in the code? A: That was Sup's original name. (Think pine, elm. Although I was a Mutt user, I couldn't think of a good progression there.) But it was taken by another project on RubyForge, and wasn't that original, and was too long to type anyways. Common Problems --------------- P: I get some error message from Rubymail about frozen strings when importing messages with attachments. S: The current solution is to directly modify RubyMail. Change line 159 of multipart.rb to: chunk = chunk[0..start] This is because RubyMail hasn't been updated since like Ruby 1.8.2. Please bug Matt Armstrong. P: I see this error: /usr/local/lib/ruby/1.8/yaml.rb:133:in `transfer': allocator undefined for Bignum (TypeError) S: You need to upgrade to Ruby 1.8.5. YAML in earlier versions can't parse BigNums, but Sup relies on that for Maildir. P: When I run Sup remotely and view an HTML attachment, an existing Firefox on the *local* machine is redirected to the attachment file, which it can't find (since it's on the remote machine). How do I view HTML attachments in this environment? S: Put this in your ~/.mailcap on the machine you run Sup on: text/html; /usr/bin/firefox -a sup %s; description=HTML Text; test=test -n "$DISPLAY"; nametemplate=%s.html Please read https://github.com/sup-heliotrope/sup/wiki/Viewing-Attachments for some security concerns on opening attachments. sup-1.1/doc/Philosophy.txt0000644000004100000410000000675114246427237015702 0ustar www-datawww-dataShould an email client have a philosophy? For many people, email is one of our primary means of communication, and email archives are an integral part of our long-term memory. Something so important ought to warrant a little thought. Here's Sup's philosophy. Using "traditional" email clients today is increasingly problematic. Anyone who's on a high-traffic mailing list knows this. My ruby-talk folder is 430 megs and Mutt sits there for 60 seconds while it opens it. Keeping up with the all the new traffic is impossible, even with Mutt's excellent threading features, simply because there's so much of it. A single thread can span several pages in the folder index view alone! And Mutt is probably the fastest, most mailing-list aware email client out there. God help me if I try and use Thunderbird. The problem with traditional clients like Mutt is that they deal with individual pieces of email. This places a high mental cost on the user for each incoming email, by forcing them to ask: Should I keep this email, or delete it? If I keep it, where should I file it? I've spent the last 10 years of my life laboriously hand-filing every email message I received and feeling a mild sense of panic every time an email was both "from Mom" and "about school". The massive amounts of email that many people receive, and the cheap cost of storage, have made these questions both more costly and less useful to answer. Contrast that with using Gmail. As a long-time Mutt user, I was blown away when I first saw someone use Gmail. They treated their email differently from how I ever had. They never filed email and they never deleted it. They relied on an immediate, global, full-text search, and thread-level tagging, to do everything I'd ever done with Mutt, but with a trivial cost to the user at message receipt time. From Gmail I learned that making certain operations quantitatively easier (namely, search) resulted in a qualitative improvement in usage. I also learned how thread-centrism was advantageous over message-centrism when message volume was high: most of the time, a message and its context deserve the same treatment. I think it's to the Gmail designers' credit that they started with a somewhat ad-hoc idea (hey, we're really good at search engines, so maybe we can build an email client on top of one) and managed to build something that was actually better than everything else out there. At least, that's how I imagine in happened. Maybe they knew what they were doing from the start. Unfortunately, there's a lot to Gmail I can't tolerate (top posting, HTML mail, one-level threads, and ads come to mind, never mind the fact that it's not FOSS). Thus Sup was born. Sup is based on the following principles, which I stole directly from Gmail: - An immediately accessible and fast search capability over the entire email archive eliminates most of the need for folders, and most of the necessity of deleting email. - Labels eliminate what little need for folders search doesn't cover. - A thread-centric approach to the UI is much more in line with how people operate than dealing with individual messages is. In the vast majority of cases, a message and its context should be subject to the same treatment. Sup is also based on many ideas from mutt and Emacs and vi, having to do with the fantastic productivity of a console- and keyboard-based application, the usefulness of multiple buffers, the necessity of handling multiple email accounts, etc. But those are just details! Try it and let me know what you think. sup-1.1/doc/Hooks.txt0000644000004100000410000000525214246427237014622 0ustar www-datawww-dataSup's Hook System ----------------- Sup can be easily customized via its hook system, which allows custom user code to be injected into Sup's execution path by "hooking" the code onto pre-defined events. When those events occur, the code is executed. To see which hooks are available, simply run sup -l. Each hook sits in a file in ~/.sup/hooks/. Hooks are written in Ruby, and require no class or method definitions, just the executable code itself. Information passes from Sup to the hook code via Ruby variables (actually method calls), and from the hook code back to Sup via a return value. The values of variables persists across calls to the same hook, but is NOT available to other hooks. To make the value of a variable available to other hooks, use the get and set methods. Each hook description lists the variables and return value expected, if any. The following special functions are available to hooks: * say msg Displays the string msg to the user at the bottom of the screen. * log msg Adds the string msg to the log, which the user can access via the buffer list. * ask_yes_or_no question Prompts the user with the string question for a yes or no response. Returns true if the user answered yes, false otherwise. * get key Gets the cross-hook value associated with key (which is typically a string). If there is no value for a given key, nil is returned. * set key value Sets the cross-hook value associated with key to value. key is typically a string, while value can be whatever type it needs to be, including nil. Some example hooks: before-poll: ## runs fetchmail before polling if (@last_fetchmail_time || Time.now) < Time.now - 60 say "Running fetchmail..." system "fetchmail >& /dev/null" say "Done running fetchmail." end @last_fetchmail_time = Time.now mime-decode: ## Please read: https://github.com/sup-heliotrope/sup/wiki/Viewing-Attachments for some security concerns on opening attachments. ## turn text/html attachments into plain text, unless they are part ## of a multipart/alternative pair require 'shellwords' unless sibling_types.member? "text/plain" case content_type when "text/html" `/usr/bin/w3m -dump -T #{content_type} #{Shellwords.escape filename}` end end startup: ## runs a background task @bgtask_pid = fork if @bgtask_pid set 'bgtask_pid' @bgtask_pid Process.detach(@bgtask_pid) # so we don't have to wait on it when we go to kill it else exec "background-task args 2&>1 >> /tmp/logfile" end after-poll: ## kills the background task after the first poll @bgtask_pid = get 'bgtask_pid' Process.kill("TERM", @bgtask_pid) unless @bgtask_pid == nil set 'bgtask_pid' nil sup-1.1/sup.gemspec0000644000004100000410000000620114246427237014400 0ustar www-datawww-data$:.push File.expand_path("../lib", __FILE__) require 'sup/version' Gem::Specification.new do |s| s.name = "sup" s.version = ENV["REL"] || (/-git-/ =~ ::Redwood::VERSION ? "999" : ::Redwood::VERSION) s.date = Time.now.strftime "%Y-%m-%d" s.authors = ["William Morgan", "Gaute Hope", "Hamish Downer", "Matthieu Rakotojaona"] s.email = "supmua@googlegroups.com" s.summary = "A console-based email client with the best features of GMail, mutt and Emacs" s.homepage = "https://sup-heliotrope.github.io/" s.license = 'GPL-2.0' s.description = <<-DESC Sup is a console-based email client for people with a lot of email. * GMail-like thread-centered archiving, tagging and muting * Handling mail from multiple mbox and Maildir sources * Blazing fast full-text search with a rich query language * Multiple accounts - pick the right one when sending mail * Ruby-programmable hooks * Automatically tracking recent contacts DESC s.post_install_message = <<-EOF SUP: please note that our old mailing lists have been shut down, re-subscribe to supmua@googlegroups.com to discuss and follow updates on sup (send email to: supmua+subscribe@googlegroups.com). OpenBSD users: If your operating system is OpenBSD you have some additional, manual steps to do before Sup will work, see: https://github.com/sup-heliotrope/sup/wiki/Installation%3A-OpenBSD. EOF s.files = File.read("Manifest.txt").split s.executables = s.files.grep(%r{^bin/}) { |f| File.basename(f) } s.test_files = s.files.grep(%r{^(test|spec|features)/}) s.require_paths = ["lib"] s.extra_rdoc_files = Dir.glob("man/*") s.required_ruby_version = '>= 2.0.0' # this is here to support skipping the xapian-ruby installation on OpenBSD # because the xapian-ruby gem doesn't install on OpenBSD, you must install # xapian-core and xapian-bindings manually on OpenBSD # see https://github.com/sup-heliotrope/sup/wiki/Installation%3A-OpenBSD # and https://en.wikibooks.org/wiki/Ruby_Programming/RubyGems#How_to_install_different_versions_of_gems_depending_on_which_version_of_ruby_the_installee_is_using s.extensions = %w[ext/mkrf_conf_xapian.rb] ## remember to update the xapian dependency in ## ext/mkrf_conf_xapian.rb and Gemfile. s.add_runtime_dependency "ncursesw", "~> 1.4.0" s.add_runtime_dependency "rmail", ">= 1.1.2", "< 2" s.add_runtime_dependency "highline" s.add_runtime_dependency "optimist" s.add_runtime_dependency "lockfile" s.add_runtime_dependency "mime-types", "> 2.0" s.add_runtime_dependency "locale", "~> 2.0" s.add_runtime_dependency "chronic" s.add_runtime_dependency "unicode", "~> 0.4.4" s.add_runtime_dependency "unicode-display_width" s.add_runtime_dependency "string-scrub" if /^2\.0\./ =~ RUBY_VERSION s.add_development_dependency "bundler", ">= 1.3", "< 3" s.add_development_dependency "rake" s.add_development_dependency 'minitest', '~> 5.5' s.add_development_dependency "rr", "~> 1.1" s.add_development_dependency "gpgme", ">= 2.0.2" s.add_development_dependency "pry" s.add_development_dependency "rubocop-packaging" unless /^2\.[012]\./ =~ RUBY_VERSION end sup-1.1/Gemfile0000644000004100000410000000041614246427237013521 0ustar www-datawww-datasource 'https://rubygems.org/' if !RbConfig::CONFIG['arch'].include?('openbsd') # update version in ext/mkrf_conf_xapian.rb as well. if /^2\.0\./ =~ RUBY_VERSION gem 'xapian-ruby', ['~> 1.2', '< 1.3.6'] else gem 'xapian-ruby', '~> 1.2' end end gemspec sup-1.1/.gitmodules0000644000004100000410000000013614246427237014402 0ustar www-datawww-data[submodule "doc/wiki"] path = doc/wiki url = https://github.com/sup-heliotrope/sup.wiki.git sup-1.1/CONTRIBUTORS0000644000004100000410000001134214246427237014106 0ustar www-datawww-dataWilliam Morgan Rich Lane Gaute Hope Whyme Lyu Dan Callaghan Hamish Downer Zeger-Jan van de Weg Damien Leone Sascha Silbe Iain Parris Eric Weikl Paweł Wilk Matthieu Rakotojaona Ismo Puustinen Nicolas Pouillard Michael Stapelberg Eric Sherman Tero Tilus Ben Walton Scott Bonds Mike Stipicevic Martin Bähr Timon Vonk Clint Byrum Wael M. Nasreddine Marcus Williams Lionel Ott Per Andersson Gaudenz Steinlin Mark Alexander Ingmar Vanhassel Edward Z. Yang julien at the macbook Christopher Warrington W. Trevor King Richard Brown Anthony Martinez Marc Hartstein Israel Herraiz Christopher Corley Markus Klinik Bo Borgerson Atte Kojo Michael Hamann Jonathan Lassoff William Erik Baxter Grant Hollingworth Ico Doornekamp Adeodato Simó Daniel Schoepe James Taylor Jason Petsod Robin Burchell Steve Goldman Peter Harkins rjg-vB Decklin Foster Cameron Matheson Carl Worth Alex Vandiver Andrew Pimlott Jeff Balogh Matías Aguirre PaulSmecker Ruthard Baudach Vickenty Fesunov Kornilios Kourtis Antoni Kaniowski Lars Fischer Sharif Olorin Steven Lawrance madhat2r Kevin Riggle Giorgio Lando Benoît PIERRE Seva Zhidkov Alvaro Herrera Jonah ian Simon Tatham Elias Norberg 0xACE <0xACE at the users.noreply.github dot coms> MichaelRevell Gregor Hoffleit Adam Lloyd Todd Eisenberger Johannes Larsen Steven Schmeiser Steven Walter Utkarsh Gupta Michael Dwyer Kyle Hunt William A. Kennington III akojo Horacio Sanson Matthias Vallentin Jon M. Dugan Stefan Lundström Kirill Smelkov sup-1.1/.github/0000755000004100000410000000000014246427237013565 5ustar www-datawww-datasup-1.1/.github/workflows/0000755000004100000410000000000014246427237015622 5ustar www-datawww-datasup-1.1/.github/workflows/checks.yml0000644000004100000410000000413114246427237017604 0ustar www-datawww-dataname: checks on: push: branches: - develop pull_request: branches: - develop permissions: contents: read jobs: rake-ci: strategy: fail-fast: false matrix: os: - ubuntu-latest - macos-latest ruby-version: - '2.0' - '2.1' # Ruby 2.2 fails installing sup gem with a nonsensical error: # Could not find 'xapian-ruby' (~> 1.2) among 21 total gem(s) #- '2.2' - '2.3' - '2.4' - '2.5' - '2.6' - '2.7' - '3.0' - '3.1' exclude: # xapian-bindings 1.2.2 fails to build on MacOS: # clang: warning: include path for libstdc++ headers not found; pass # '-stdlib=libc++' on the command line to use the libc++ standard # library instead [-Wstdlibcxx-not-found] # xapian_wrap.cc:1836:10: fatal error: 'string' file not found # Probably just wrong compiler command or some mess that I can't be # bothered to figure out. - os: macos-latest ruby-version: '2.0' # xapian-bindings 1.4.18 fails to build with Ruby 3.0+ on MacOS: # error: '__declspec' attributes are not enabled; use '-fdeclspec' or # '-fms-extensions' to enable support for __declspec attributes # Needs this fix: # https://github.com/xapian/xapian/commit/63a06768a250b0bb4821b835f011e8214d560f8e - os: macos-latest ruby-version: '3.0' - os: macos-latest ruby-version: '3.1' runs-on: ${{ matrix.os }} steps: - name: Install pandoc run: sudo apt-get install -y pandoc if: runner.os == 'Linux' - name: Install pandoc run: brew install pandoc if: runner.os == 'macOS' - uses: actions/checkout@v3 with: submodules: recursive - name: Set up Ruby uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby-version }} bundler-cache: true - name: Run Rake ci task run: bundle exec rake ci - name: Test gem installation run: gem install pkg/sup-*.gem sup-1.1/Manifest.txt0000644000004100000410000000737614246427237014551 0ustar www-datawww-data.github/workflows/checks.yml .gitignore .gitmodules .rubocop.yml CONTRIBUTORS Gemfile HACKING History.txt LICENSE Manifest.txt README.md Rakefile ReleaseNotes bin/sup bin/sup-add bin/sup-config bin/sup-dump bin/sup-import-dump bin/sup-recover-sources bin/sup-sync bin/sup-sync-back-maildir bin/sup-tweak-labels contrib/colorpicker.rb contrib/completion/_sup.bash contrib/completion/_sup.zsh devel/console.sh devel/count-loc.sh devel/load-index.rb devel/profile.rb devel/start-console.rb doc/FAQ.txt doc/Hooks.txt doc/Philosophy.txt doc/wiki ext/mkrf_conf_xapian.rb lib/sup.rb lib/sup/account.rb lib/sup/buffer.rb lib/sup/colormap.rb lib/sup/contact.rb lib/sup/crypto.rb lib/sup/draft.rb lib/sup/hook.rb lib/sup/horizontal_selector.rb lib/sup/idle.rb lib/sup/index.rb lib/sup/interactive_lock.rb lib/sup/keymap.rb lib/sup/label.rb lib/sup/logger.rb lib/sup/logger/singleton.rb lib/sup/maildir.rb lib/sup/mbox.rb lib/sup/message.rb lib/sup/message_chunks.rb lib/sup/mode.rb lib/sup/modes/buffer_list_mode.rb lib/sup/modes/completion_mode.rb lib/sup/modes/compose_mode.rb lib/sup/modes/console_mode.rb lib/sup/modes/contact_list_mode.rb lib/sup/modes/edit_message_async_mode.rb lib/sup/modes/edit_message_mode.rb lib/sup/modes/file_browser_mode.rb lib/sup/modes/forward_mode.rb lib/sup/modes/help_mode.rb lib/sup/modes/inbox_mode.rb lib/sup/modes/label_list_mode.rb lib/sup/modes/label_search_results_mode.rb lib/sup/modes/line_cursor_mode.rb lib/sup/modes/log_mode.rb lib/sup/modes/person_search_results_mode.rb lib/sup/modes/poll_mode.rb lib/sup/modes/reply_mode.rb lib/sup/modes/resume_mode.rb lib/sup/modes/scroll_mode.rb lib/sup/modes/search_list_mode.rb lib/sup/modes/search_results_mode.rb lib/sup/modes/text_mode.rb lib/sup/modes/thread_index_mode.rb lib/sup/modes/thread_view_mode.rb lib/sup/person.rb lib/sup/poll.rb lib/sup/rfc2047.rb lib/sup/search.rb lib/sup/sent.rb lib/sup/service/label_service.rb lib/sup/source.rb lib/sup/tagger.rb lib/sup/textfield.rb lib/sup/thread.rb lib/sup/time.rb lib/sup/undo.rb lib/sup/update.rb lib/sup/util.rb lib/sup/util/axe.rb lib/sup/util/locale_fiddler.rb lib/sup/util/ncurses.rb lib/sup/util/path.rb lib/sup/util/query.rb lib/sup/util/uri.rb lib/sup/version.rb sup.gemspec test/dummy_source.rb test/fixtures/bad-content-transfer-encoding-1.eml test/fixtures/binary-content-transfer-encoding-2.eml test/fixtures/blank-header-fields.eml test/fixtures/contacts.txt test/fixtures/embedded-message.eml test/fixtures/mailing-list-header.eml test/fixtures/malicious-attachment-names.eml test/fixtures/missing-from-to.eml test/fixtures/missing-line.eml test/fixtures/multi-part-2.eml test/fixtures/multi-part.eml test/fixtures/no-body.eml test/fixtures/non-ascii-header-in-nested-message.eml test/fixtures/non-ascii-header.eml test/fixtures/rfc2047-header-encoding.eml test/fixtures/simple-message.eml test/fixtures/text-attachments-with-charset.eml test/fixtures/utf8-header.eml test/fixtures/zimbra-quote-with-bottom-post.eml test/gnupg_test_home/.gpg-v21-migrated test/gnupg_test_home/gpg.conf test/gnupg_test_home/private-keys-v1.d/306D2EE90FF0014B5B9FD07E265C751791674140.key test/gnupg_test_home/pubring.gpg test/gnupg_test_home/receiver_pubring.gpg test/gnupg_test_home/receiver_secring.gpg test/gnupg_test_home/regen_keys.sh test/gnupg_test_home/secring.gpg test/gnupg_test_home/sup-test-2@foo.bar.asc test/integration/test_maildir.rb test/integration/test_mbox.rb test/integration/test_sup-add.rb test/test_crypto.rb test/test_header_parsing.rb test/test_helper.rb test/test_message.rb test/test_messages_dir.rb test/test_yaml_regressions.rb test/unit/service/test_label_service.rb test/unit/test_contact.rb test/unit/test_horizontal_selector.rb test/unit/test_locale_fiddler.rb test/unit/test_person.rb test/unit/util/test_query.rb test/unit/util/test_string.rb test/unit/util/test_uri.rb sup-1.1/ext/0000755000004100000410000000000014246427237013025 5ustar www-datawww-datasup-1.1/ext/mkrf_conf_xapian.rb0000644000004100000410000000236514246427237016664 0ustar www-datawww-datarequire 'rubygems' require 'rubygems/command.rb' require 'rubygems/dependency_installer.rb' require 'rbconfig' begin Gem::Command.build_args = ARGV rescue NoMethodError end puts "xapian: platform specific dependencies.." destination = File.writable?(Gem.dir) ? Gem.dir : Gem.user_dir inst = Gem::DependencyInstaller.new(:install_dir => destination) begin if !RbConfig::CONFIG['arch'].include?('openbsd') # update version in Gemfile as well name = "xapian-ruby" version = if /^2\.0\./ =~ RUBY_VERSION ["~> 1.2", "< 1.3.6"] else "~> 1.2" end begin # try to load gem gem name, version STDERR.puts "xapian: already installed." rescue Gem::LoadError STDERR.puts "xapian: installing xapian-ruby.." inst.install name, version end else STDERR.puts "xapian: openbsd: you have to install xapian-core and xapian-bindings manually, have a look at: https://github.com/sup-heliotrope/sup/wiki/Installation%3A-OpenBSD" end rescue StandardError => e STDERR.puts "Unable to install #{name} gem: #{e.inspect}" exit(1) end # create dummy rakefile to indicate success f = File.open(File.join(File.dirname(__FILE__), "Rakefile"), "w") f.write("task :default\n") f.close sup-1.1/HACKING0000644000004100000410000000367714246427237013231 0ustar www-datawww-dataRunning Sup from your git checkout ---------------------------------- Invoke it like this: ruby -I lib -w bin/sup You'll have to install all gems mentioned in the Rakefile (look for the line setting p.extra_deps). If you're on a Debian or Debian-based system (e.g. Ubuntu), you'll have to make sure you have a complete Ruby installation, especially libssl-ruby. You will need libruby-devel, gcc, and rake installed to build certain gems like Xapian. Gem install does not do a good job of detecting when these things are missing and the build fails. Rubygems also is particularly aggressive about picking up libraries from installed gems. If you do have Sup installed as a gem, please examine backtraces to make sure you're loading files from the repository and NOT from the installed gem before submitting any bug reports. Coding standards ---------------- - Don't wrap code unless it really benefits from it. - Do wrap comments at 72 characters. - Old lisp-style comment differentiations: # one for comments on the same line as a line of code ## two for comments on their own line, except: ### three for comments that demarcate large sections of code (rare) - Use {} for one-liner blocks and do/end for multi-line blocks. - I like poetry mode. Don't use parentheses unless you must. - The one exception to poetry mode is if-statements that have an assignment in the condition. To make it clear this is not a comparison, surround the condition by parentheses. E.g.: if a == b if(a = some.computation) ... BUT ... something with a end end - and/or versus ||/&&. In Ruby, "and" and "or" bind very loosely---even more loosely than function application. This makes them ideal for end-of-line short-circuit control in poetry mode. So, use || and && for ordinary logical comparisons, and "and" and "or" for end-of-line flow control. E.g.: x = a || b or raise "neither is true"