├── .gitignore ├── Gemfile ├── README.md ├── bin └── littleredflag ├── lib ├── core_ext │ └── string.rb ├── little_red_flag.rb └── little_red_flag │ ├── mail_agent.rb │ ├── mail_agent │ ├── mbsync.rb │ ├── mbsync │ │ └── config.rb │ ├── mu.rb │ └── notmuch.rb │ ├── maildir.rb │ ├── ping.rb │ └── version.rb └── little_red_flag.gemspec /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | /.config 4 | /coverage/ 5 | /InstalledFiles 6 | /pkg/ 7 | /spec/reports/ 8 | /spec/examples.txt 9 | /test/tmp/ 10 | /test/version_tmp/ 11 | /tmp/ 12 | 13 | # Used by dotenv library to load environment variables. 14 | # .env 15 | 16 | ## Specific to RubyMotion: 17 | .dat* 18 | .repl_history 19 | build/ 20 | *.bridgesupport 21 | build-iPhoneOS/ 22 | build-iPhoneSimulator/ 23 | 24 | ## Specific to RubyMotion (use of CocoaPods): 25 | # 26 | # We recommend against adding the Pods directory to your .gitignore. However 27 | # you should judge for yourself, the pros and cons are mentioned at: 28 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 29 | # 30 | # vendor/Pods/ 31 | 32 | ## Documentation cache and generated files: 33 | /.yardoc/ 34 | /_yardoc/ 35 | /doc/ 36 | /rdoc/ 37 | 38 | ## Environment normalization: 39 | /.bundle/ 40 | /vendor/bundle 41 | /lib/bundler/man/ 42 | 43 | # for a library or gem, you might want to ignore these files since the code is 44 | # intended to run in multiple environments; otherwise, check them in: 45 | Gemfile.lock 46 | .ruby-version 47 | .ruby-gemset 48 | .rubocop 49 | 50 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 51 | .rvmrc 52 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | group :development do 6 | gem 'pry' 7 | gem 'rspec' 8 | gem 'rubocop', require: false 9 | end 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This project is broken and has been abandoned. 2 | ============================================== 3 | 4 | If you are looking for an alternative 5 | (_i.e.,_ a daemon / background service to monitor your inbox 6 | and trigger an IMAP sync utility whenever new mail arrives), 7 | try [gnubiff](http://gnubiff.sourceforge.net/). 8 | 9 | Gnubiff’s _intended purpose_ is to provide a GUI widget 10 | that lives in the corner of your screen 11 | and shows a preview of new emails as they come in. 12 | However, you can run it in **headless mode** 13 | and configure it to run whatever command you want 14 | when a new incoming message has been detected 15 | (_e.g.,_ `mbsync -a`). 16 | 17 | You’ll have to launch it in GUI mode at least once 18 | to add your IMAP credentials, 19 | and then manually edit the config file 20 | to tell it how to fetch your mail: 21 | 22 | ```xml 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | ``` 31 | 32 | > **Note:** 33 | > If your mail fetching utility invokes a password manager like [pass][] 34 | > to avoid storing passwords in plain text, 35 | > you might have to set some environment variables 36 | > at the start of the `newmail_command` above, like: 37 | > 38 | > ```sh 39 | > value="export GNUPGHOME='/home/rlue/.config/gnupg'; export PASSWORD_STORE_DIR='/home/rlue/.config/pass'; pgrep ..." 40 | > ``` 41 | 42 | Then, you can set it up and launch it as a systemd service: 43 | 44 | ```service 45 | # ~/.local/share/systemd/user/gnubiff.service 46 | 47 | [Unit] 48 | Description=Gnubiff Headless Email Fetcher 49 | After=network.target 50 | 51 | [Service] 52 | ExecStart=/usr/bin/gnubiff --noconfigure --nogui 53 | Restart=on-failure 54 | 55 | [Install] 56 | WantedBy=default.target 57 | ``` 58 | 59 | ```sh 60 | $ systemctl --user enable gnubiff 61 | $ systemctl --user start gnubiff 62 | ``` 63 | 64 | 📬 Little Red Flag 65 | ================== 66 | 67 | ### Sync IMAP mail to your machine. Automatically, instantly, all the time. 68 | 69 | Requires [isync][isync]. 70 | 71 | **isync** (`mbsync`) is a command-line tool for synchronizing IMAP and local Maildir mailboxes. It’s faster and stabler than the next most popular alternative (OfflineIMAP), but still must be invoked manually. **Little Red Flag** keeps an eye on your mailboxes and runs the appropriate `mbsync` command anytime changes occur, **whether locally or remotely**. It also detects the presence of `mu` / `notmuch` mail indexers, and re-indexes after each sync. 72 | 73 | Little Red Flag is smart: it only syncs once it’s confirmed that the specified IMAP server is reachable. Remote changes are monitored with IMAP IDLE, and dropped connections are renewed within 60 seconds. 74 | 75 | (In fact, it would be ideal if isync implemented this functionality itself, but according to the project maintainer, such plans are [vague and indefinitely postponed][postponed]. If I knew the first thing about C, I’d have taken a stab at improving isync myself; this utility is the next best thing I knew how to make.) 76 | 77 | Installation 78 | ------------ 79 | 80 | ```bash 81 | $ gem install little_red_flag 82 | ``` 83 | 84 | Usage 85 | ----- 86 | 87 | Call `littleredflag` with same arguments you would use for mbsync: 88 | 89 | ```bash 90 | $ littleredflag -a 91 | ``` 92 | 93 | listens for changes on all remote IMAP folders. Specify one or more channels/groups (as defined in your `.mbsyncrc`) to watch all IMAP folders contained in them. 94 | 95 | You may find it convenient to define a group for all mailboxes you wish to monitor: 96 | 97 | ``` 98 | # ~/.mbsyncrc 99 | Group inboxes 100 | Channel gmail-inbox 101 | Channel gmail-drafts 102 | Channel gmail-sent 103 | Channel gmail-starred 104 | ``` 105 | 106 | Then: 107 | 108 | ```bash 109 | $ littleredflag inboxes 110 | ``` 111 | 112 | Locally, Little Red Flag watches paths specified in `MaildirStore` sections of your `.mbsyncrc`, and thus will detect local changes in _any_ mail folder. 113 | 114 | **Synchronizations are performed only on mail folders where changes are detected.** If you’re only monitoring your INBOX, receiving new mail to it will not cause any other folders to sync. (This behavior can be reversed with the `-g` command line option.) 115 | 116 | ### In `.bash_profile` 117 | 118 | For best results, run Little Red Flag on login. 119 | 120 | One way to do that is to add it to your `.bash_profile`. The following script will launch Little Red Flag idempotently (that is, it will only launch if there is no other instance of Little Red Flag running that was originated by this same script): 121 | 122 | ```bash 123 | mkdir -p "$HOME/tmp" 124 | PIDFILE="$HOME/tmp/littleredflag.pid" 125 | 126 | if [ -e "${PIDFILE}" ] && (ps -u $(whoami) -opid= | 127 | grep "$(cat ${PIDFILE})" &> /dev/null); then 128 | : 129 | else 130 | littleredflag > "$HOME/tmp/littleredflag.log" 2>&1 & 131 | 132 | echo $! > "${PIDFILE}" 133 | chmod 644 "${PIDFILE}" 134 | fi 135 | ``` 136 | 137 | Config 138 | ------ 139 | 140 | Little Red Flag does not accept a configuration dotfile. It extracts the relevant settings from the `~/.mbsyncrc` file, and detects mu and notmuch on the basis of their respective dotfiles, as well. 141 | 142 | Currently, Little Red Flag only looks for these dotfiles in their default location. Future versions may support a command line option to specify config file locations. 143 | 144 | License 145 | ------- 146 | 147 | The MIT License (MIT) 148 | 149 | Copyright © 2017 Ryan Lue 150 | 151 | [pass]: https://www.passwordstore.org/ 152 | [isync]: http://isync.sourceforge.net/ 153 | [listen]: https://github.com/guard/listen 154 | [postponed]: https://sourceforge.net/p/isync/feature-requests/8/#173f 155 | -------------------------------------------------------------------------------- /bin/littleredflag: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'little_red_flag' 4 | require 'listen' 5 | require 'optparse' 6 | require 'pry' 7 | require 'sys-proctable' 8 | include Sys 9 | 10 | opts = {} 11 | OptionParser.new do |o| 12 | o.banner = 'Usage: little-red-flag [options] CHANNEL[,...]' 13 | 14 | o.on('-v', '--[no-]verbose', 'Run verbosely') do 15 | opts[:verbose] = true 16 | end 17 | 18 | o.on('-a', '--all', 'Watch all mailboxes') do 19 | opts[:all] = true 20 | end 21 | 22 | o.on('-g', '--global', 'Sync all mailboxes on each change') do 23 | opts[:global] = true 24 | end 25 | 26 | o.on('-m', '--mail-agent NAME[,...]', Array, 'Specify mail agents') do |agents| 27 | opts[:agents] = agents.map(&:to_sym) 28 | end 29 | end.parse! 30 | 31 | def timestamp 32 | Time.now.strftime("[%v %X]") 33 | end 34 | 35 | puts "#{timestamp} Parsing configuration files..." if opts[:verbose] 36 | 37 | mail_agents = opts[:agents] || LittleRedFlag::MailAgent.detect 38 | mail_agents.map! { |agent| LittleRedFlag::MailAgent.new(agent) } 39 | mra = mail_agents.find(&:mra?) 40 | indexer = mail_agents.find(&:indexer?) 41 | 42 | to_sync = Hash.new { |h, k| h[k] = [] } 43 | mailstores = mra.stores 44 | 45 | puts "#{timestamp} Performing initial sync..." if opts[:verbose] 46 | system(mra.command) 47 | system(indexer.command) if indexer 48 | 49 | listener = Listen.to(*mailstores) do |mod, add, rm| 50 | unless ProcTable.ps.any? { |p| p.comm =~ /#{mra.command.split.first}/ } 51 | changes = [mod, add, rm].flatten 52 | changes.map { |f| LittleRedFlag::Maildir.of(f) }.uniq 53 | .map { |mdir| mra.channel_for(mdir) }.uniq 54 | .each do |channel| 55 | puts "#{timestamp} Local changes detected" if opts[:verbose] 56 | account = mra.account_for(channel) 57 | if to_sync[account.label.to_sym].empty? 58 | Thread.new do 59 | loop do 60 | sleep 10 61 | break if LittleRedFlag::Ping.new(account.host).ping 62 | end 63 | to_sync[account.label.to_sym].uniq! 64 | if opts[:global] 65 | system(mra.command) 66 | to_sync[account.label.to_sym].clear 67 | puts "#{timestamp} All channels successfully synced" if opts[:verbose] 68 | else 69 | until to_sync[account.label.to_sym].empty? 70 | c = to_sync[account.label.to_sym].shift 71 | system(mra.command(c)) 72 | puts "#{timestamp} Channel #{c} successfully synced" if opts[:verbose] 73 | end 74 | end 75 | system(indexer.command) if indexer 76 | puts "#{timestamp} #{indexer.name} index rebuilt" if opts[:verbose] 77 | end 78 | end 79 | 80 | to_sync[account.label.to_sym] << channel 81 | puts "#{timestamp} Channel #{channel} added to sync queue" if opts[:verbose] 82 | end 83 | end 84 | end 85 | mail_agents.each do |agent| 86 | listener.ignore(agent.ignore_pat) if agent.respond_to?(:ignore_pat) 87 | end 88 | listener.start 89 | 90 | mailstores.each { |store| puts "#{timestamp} Watching for local changes in #{store}" } if opts[:verbose] 91 | 92 | inboxes = [] 93 | if opts[:all] 94 | mra.config.channels.each do |channel| 95 | inboxes.push(channel.inboxes).flatten! 96 | inboxes.uniq! 97 | end 98 | elsif ARGV.any? 99 | ARGV.each do |name| 100 | channel = [mra.config.channels, mra.config.groups].flatten.find { |channel| channel.label == name } 101 | raise ArgumentError, "#{name}: no such channel or group" unless channel 102 | inboxes.push(channel.inboxes).flatten! 103 | end 104 | else 105 | inboxes.push(mra.config.imapstores.map { |store| store.inbox }).flatten! 106 | end 107 | 108 | channel_threads = {} 109 | 110 | inboxes.each do |inbox| 111 | Thread.new do 112 | name = inbox.channel.to_sym 113 | inbox.account.connections[name] = inbox.account.connect 114 | inbox.account.connections[name].examine(inbox.folder) 115 | loop do 116 | channel = mra.config.channels.find { |channel| channel.label == inbox.channel.split(':').first } 117 | if LittleRedFlag::Ping.new(inbox.account.host).ping 118 | if channel_threads.keys.include?(name) 119 | if channel.behind? 120 | channel.caught_up! 121 | puts "#{timestamp} Syncing channel #{channel.label} after dropped connection..." if opts[:verbose] 122 | if opts[:global] 123 | system(mra.command) 124 | puts "#{timestamp} All channels successfully synced" if opts[:verbose] 125 | else 126 | system(mra.command(channel.label)) 127 | puts "#{timestamp} Channel #{channel.label} successfully synced" if opts[:verbose] 128 | end 129 | system(indexer.command) if indexer 130 | puts "#{timestamp} #{indexer.name} index rebuilt" if opts[:verbose] 131 | 132 | Thread.kill(channel_threads[name]) 133 | channel_threads[name] = Thread.new do 134 | inbox.account.connections[name].idle do |res| 135 | res_data = res.data.respond_to?(:text) ? res.data.text : res.name 136 | puts "#{timestamp} IDLE response received on #{inbox.channel}:#{inbox.folder}: #{res_data}" if opts[:verbose] 137 | if res.respond_to?(:name) && %w(EXISTS FETCH).include?(res.name) 138 | if opts[:global] 139 | system(mra.command) 140 | puts "#{timestamp} All channels successfully synced" if opts[:verbose] 141 | else 142 | system(mra.command(inbox.channel)) 143 | puts "#{timestamp} Channel #{inbox.channel} successfully synced" if opts[:verbose] 144 | end 145 | system(indexer.command) if indexer 146 | puts "#{timestamp} #{indexer.name} index rebuilt" if opts[:verbose] 147 | end 148 | end 149 | end 150 | end 151 | else 152 | channel_threads[name] = Thread.new do 153 | inbox.account.connections[name].idle do |res| 154 | res_data = res.data.respond_to?(:text) ? res.data.text : res.name 155 | puts "#{timestamp} IDLE response received on #{inbox.channel}:#{inbox.folder}: #{res_data}" if opts[:verbose] 156 | if res.respond_to?(:name) && %w(EXISTS FETCH).include?(res.name) 157 | if opts[:global] 158 | system(mra.command) 159 | puts "#{timestamp} All channels successfully synced" if opts[:verbose] 160 | else 161 | system(mra.command(inbox.channel)) 162 | puts "#{timestamp} Channel #{inbox.channel} successfully synced" if opts[:verbose] 163 | end 164 | system(indexer.command) if indexer 165 | puts "#{timestamp} #{indexer.name} index rebuilt" if opts[:verbose] 166 | end 167 | end 168 | end 169 | end 170 | 171 | sleep 60 172 | else 173 | puts "#{timestamp} IDLE connection lost" if (opts[:verbose] && !channel.behind?) 174 | channel.behind! 175 | sleep 60 176 | end 177 | end 178 | end 179 | end 180 | 181 | sleep 182 | -------------------------------------------------------------------------------- /lib/core_ext/string.rb: -------------------------------------------------------------------------------- 1 | class String 2 | def intersection(str) 3 | return '' if [self, str].any?(&:empty?) 4 | 5 | matrix = Array.new(self.length) { Array.new(str.length) { 0 } } 6 | 7 | intersection = Struct.new(:length, :end) do 8 | def start 9 | self.end - length + 1 10 | end 11 | end.new(0, 0) 12 | 13 | self.length.times do |x| 14 | str.length.times do |y| 15 | next unless self[x] == str[y] 16 | matrix[x][y] = 1 + (([x, y].all?(&:zero?)) ? 0 : matrix[x-1][y-1]) 17 | 18 | next unless matrix[x][y] > intersection.length 19 | intersection.length = matrix[x][y] 20 | intersection.end = x 21 | end 22 | end 23 | 24 | slice(intersection.start..intersection.end) 25 | end 26 | 27 | def unescape 28 | self.gsub(/^(!)?"(.*)"$/, '\1\2').gsub(/\\/, '') 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/little_red_flag.rb: -------------------------------------------------------------------------------- 1 | require 'core_ext/string' 2 | require 'little_red_flag/ping' 3 | require 'little_red_flag/mail_agent' 4 | require 'little_red_flag/mail_agent/mbsync/config.rb' 5 | require 'little_red_flag/mail_agent/mbsync' 6 | require 'little_red_flag/mail_agent/mu' 7 | require 'little_red_flag/mail_agent/notmuch' 8 | require 'little_red_flag/maildir' 9 | -------------------------------------------------------------------------------- /lib/little_red_flag/mail_agent.rb: -------------------------------------------------------------------------------- 1 | module LittleRedFlag 2 | # Scrapes IMAP account settings from MRA config files 3 | module MailAgent 4 | AGENT_MAP = { mbsync: { role: :mra, config: '.mbsyncrc' }, 5 | mu: { role: :indexer, config: '.mu' }, 6 | notmuch: { role: :indexer, config: '.notmuch-config' }, 7 | offlineimap: { role: :mra, config: '.offlineimaprc' } } 8 | 9 | class << self 10 | def new(agent) 11 | validate(agent) 12 | const_get(agent.to_s.capitalize).new 13 | end 14 | 15 | def validate(agent) 16 | check_supported(agent) 17 | check_config(agent) 18 | check_roles(agent) 19 | end 20 | 21 | def detect 22 | AGENT_MAP.select { |_k, v| File.exist?(path_to(v[:config])) }.keys 23 | end 24 | 25 | def path_to(config) 26 | "#{ENV['HOME']}/#{config}" 27 | end 28 | 29 | private 30 | 31 | def check_supported(agent) 32 | raise ArgumentError, "#{agent} is not a supported mail agent" \ 33 | unless AGENT_MAP.keys.include?(agent) 34 | end 35 | 36 | def check_config(agent) 37 | raise "#{path_to(AGENT_MAP[agent][:config])} not found" \ 38 | unless File.exist?(path_to(AGENT_MAP[agent][:config])) 39 | end 40 | 41 | def check_roles(agent) 42 | @@role_rosters ||= Hash.new { |k, v| k[v] = [] } 43 | roster = @@role_rosters[AGENT_MAP[agent][:role]] 44 | roster << agent 45 | if roster.length > 1 46 | raise "Conflicting mail agents detected " \ 47 | "(#{roster.map(&:to_s).join(', ')}).\n" \ 48 | "Remove dotfiles associated with unused mail agents,\n" \ 49 | "or specify active ones explicitly with the -a flag:\n\n" \ 50 | " little-red-flag -a mbsync,notmuch" 51 | end 52 | end 53 | end 54 | 55 | def name 56 | self.class.name.downcase.split('::').last 57 | end 58 | 59 | # TODO: fix this... 60 | def dotfile 61 | MailAgent.path_to(AGENT_MAP[name.to_sym][:config]) 62 | end 63 | 64 | def mra? 65 | false 66 | end 67 | 68 | def indexer? 69 | false 70 | end 71 | 72 | Server = Struct.new(:host, :port, :ssl) 73 | Credentials = Struct.new(:user, :pass) # TODO: address pass v. passcmd 74 | Account = Struct.new(:server, :credentials, :connection) do 75 | def listen(mbox = 'INBOX') 76 | connection.examine(mbox) 77 | connection.idle do |res| 78 | yield if %w(EXISTS FETCH).include?(res.name) 79 | end 80 | end 81 | 82 | def initiate_idle 83 | self.connection = Net::IMAP.new(server.host, 84 | port: server.port, 85 | ssl: server.ssl) 86 | connection.authenticate('PLAIN', credentials.user, credentials.pass) 87 | end 88 | 89 | def close_idle 90 | connection.idle_done 91 | end 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /lib/little_red_flag/mail_agent/mbsync.rb: -------------------------------------------------------------------------------- 1 | module LittleRedFlag 2 | module MailAgent 3 | # Controller for the mbsync mail retrieval agent 4 | class Mbsync 5 | include MailAgent 6 | 7 | attr_reader :config 8 | 9 | def initialize 10 | @config = Config.new(dotfile) 11 | end 12 | 13 | def mra? 14 | true 15 | end 16 | 17 | def command(channel = :all) 18 | 'mbsync ' + (channel == :all ? '-a' : channel) 19 | end 20 | 21 | def ignore_pat 22 | /\.mbsync/ 23 | end 24 | 25 | # returns an array of strings representing paths to local mailstores 26 | def stores 27 | @stores ||= config.maildirstores.map(&:path) 28 | end 29 | 30 | def channels 31 | @channels ||= config.select { |sxn| sxn.key?(:channel) } 32 | .map! do |st| 33 | st.key?(:group) ? st[:group] : st[:channel] 34 | end 35 | end 36 | 37 | # takes the path of a maildir, 38 | # returns the channel.label ( + :mailfolder) most closely matching it 39 | def channel_for(maildir) 40 | @channel ||= {} 41 | @channel[maildir.to_sym] ||= begin 42 | channel = config.channels.max_by do |channel| 43 | localstore_path = channel.localstore.path_of[channel.label.to_sym] 44 | maildir.slice(localstore_path) ? localstore_path.intersection(maildir).length : 0 45 | end 46 | 47 | localstore_path = channel.localstore.path_of[channel.label.to_sym] 48 | 49 | folder = maildir.sub(%r{#{localstore_path}/?}, '') 50 | .gsub(channel.localstore.flatten.unescape, 51 | (channel.remotestore.pathdelimiter || '/')) 52 | 53 | channel.label + (folder.empty? ? '' : ":#{folder}") 54 | end 55 | end 56 | 57 | def account_for(channel) 58 | @account ||= {} 59 | @account[channel.to_sym] ||= begin 60 | config.channels.find { |c| channel.split(':').first == c.label }.account 61 | end 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/little_red_flag/mail_agent/mbsync/config.rb: -------------------------------------------------------------------------------- 1 | require 'net/imap' 2 | 3 | module LittleRedFlag 4 | module MailAgent 5 | class Mbsync 6 | # Stores .mbsyncrc configuration file data 7 | class Config 8 | CASE_SENSITIVE = %w(path inbox master slave pattern patterns pass).freeze 9 | 10 | Imapaccount = Struct.new(:label, :host, :port, :user, :pass, :passcmd, 11 | :tunnel, :authmechs, :ssltype, :sslversions, :systemcertificates, 12 | :certificatefile, :pipelinedepth, :connections) do 13 | def connect 14 | until Ping.new(host).ping 15 | 10.downto(1) do |i| 16 | printf "#{host} unreachable. Trying again in #{i}s... \r" 17 | sleep 1 18 | end 19 | puts "#{host} unreachable. Trying again... " 20 | end 21 | imap = Net::IMAP.new(host, port: port, ssl: (ssltype && (ssltype.downcase == 'imaps'))) 22 | @auth ||= %w(LOGIN PLAIN) & imap.capability 23 | .select { |cap| cap['AUTH='] } 24 | .map { |auth| auth.sub('AUTH=','').upcase } 25 | imap.authenticate(@auth.first, user, pass || `#{passcmd.unescape}`.chomp) 26 | imap 27 | end 28 | end 29 | Imapstore = Struct.new(:label, :path, :maxsize, :mapinbox, :flatten, 30 | :trash, :trashnewonly, :trashremotenew, :account, :usenamespace, 31 | :pathdelimiter, :path_of, :inbox) 32 | Maildirstore = Struct.new(:label, :path, :maxsize, :mapinbox, :flatten, 33 | :trash, :trashnewonly, :trashremotenew, :altmap, :inbox, 34 | :infodelimiter, :path_of) 35 | Channel = Struct.new(:label, :master, :slave, :patterns, :maxsize, 36 | :maxmessages, :expireunread, :sync, :create, :remove, :expunge, 37 | :copyarrivaldate, :syncstate, :localstore, :remotestore, :inboxes) do 38 | def localpath 39 | localstore.path_of[label.to_sym] 40 | end 41 | 42 | def localpath=(path) 43 | localstore.path_of ||= {} 44 | localstore.path_of[label.to_sym] = path 45 | end 46 | 47 | def remotepath 48 | remotestore.path_of[label.to_sym] 49 | end 50 | 51 | def remotepath=(path) 52 | remotestore.path_of ||= {} 53 | remotestore.path_of[label.to_sym] = path 54 | end 55 | 56 | def account 57 | remotestore.account 58 | end 59 | 60 | def behind? 61 | @behind 62 | end 63 | 64 | def behind! 65 | @behind = true 66 | end 67 | 68 | def caught_up! 69 | @behind = false 70 | end 71 | end 72 | Group = Struct.new(:label, :channels, :inboxes) 73 | Inbox = Struct.new(:account, :folder, :channel) do 74 | def listen(interval=60, &block) 75 | name = folder.split('/').last.to_sym 76 | account.connections[name] = account.connect 77 | account.connections[name].examine(folder) 78 | Thread.new do 79 | loop { account.connections[name].idle(interval, &block) } 80 | end 81 | end 82 | end 83 | 84 | def initialize(dotfile) 85 | raw_data = sanitize(File.read(dotfile)) 86 | settings = structify(arrayify(raw_data)) 87 | populate_instance_variables(settings) 88 | postprocess 89 | end 90 | 91 | private 92 | 93 | # removes comments / normalizes whitespace 94 | def sanitize(config) 95 | config.gsub(/^#.*\n/, '').gsub(/\n+(?=\n\n)/, '').strip 96 | end 97 | 98 | # Takes text from #sanitize and returns a 3D array of SECTION arrays 99 | # of the form [["OPTION", "VALUE"], ["OPTION", "VALUE"]...] 100 | def arrayify(config) 101 | config.split("\n\n").map! do |section| 102 | section.split("\n").map! do |setting| 103 | key, val = *setting.split(/\s+/, 2) 104 | key.downcase! 105 | val.downcase! unless CASE_SENSITIVE.include?(key) 106 | [key, val] 107 | end 108 | end 109 | end 110 | 111 | # Takes a 3D array from #arrayify and returns an array of structs, 112 | # removing any global options 113 | def structify(config) 114 | config.map! do |section| 115 | struct_name, label = *section.shift 116 | next unless self.class.const_defined?(struct_name.capitalize!) 117 | section = hashify(section) 118 | struct = self.class.const_get(struct_name) 119 | struct.new(label, *section.values_at(*struct.members.drop(1))) 120 | end 121 | 122 | config.select { |section| section.respond_to?(:label) } 123 | end 124 | 125 | # Converts a single section's 2D array of settings into a hash, 126 | # collecting multiple values with the same key into arrays 127 | # and consolidating `Channel[s]`/`Pattern[s]` keywords 128 | def hashify(section) 129 | section.each.with_object({}) do |setting, hash| 130 | key, val = *setting 131 | key = (%w(channel pattern).include?(key) ? "#{key}s" : key).to_sym 132 | hash[key] = hash.key?(key) ? [hash[key], val].flatten : val 133 | end 134 | end 135 | 136 | def populate_instance_variables(var_array) 137 | var_array.each do |section| 138 | name = "#{section.class.name.split('::').last.downcase}s" 139 | unless instance_variable_defined?("@#{name}") 140 | instance_variable_set("@#{name}", []) 141 | self.class.send(:define_method, name.to_sym) do 142 | instance_variable_get("@#{name}") 143 | end 144 | end 145 | 146 | instance_variable_get("@#{name}") << section 147 | end 148 | end 149 | 150 | def postprocess 151 | expand_paths 152 | populate_channel_stores 153 | link_imapstore_accounts 154 | link_group_channels 155 | populate_imapstore_inboxes 156 | populate_channel_inboxes 157 | populate_group_inboxes 158 | end 159 | 160 | def expand_paths 161 | maildirstores.each do |store| 162 | %i(path inbox).each do |path| 163 | store.send("#{path}=", File.expand_path(store.send(path).unescape)) \ 164 | unless store.send(path).nil? 165 | end 166 | end 167 | end 168 | 169 | def populate_channel_stores 170 | channels.each do |channel| 171 | [channel.master, channel.slave].each do |store| 172 | name = store.split(':')[1] 173 | if path_map.key?(name) 174 | channel.localstore = maildirstores.find { |m| m.label == name } 175 | channel.localpath = store.sub(/(?<=:)(?=[^:]+$)/, '/') 176 | .sub(":#{name}:", path_map[name]) 177 | else 178 | channel.remotestore = imapstores.find { |m| m.label == name } 179 | channel.remotepath = store.sub(":#{name}:", '').unescape 180 | end 181 | end 182 | end 183 | end 184 | 185 | def path_map 186 | @stores ||= maildirstores.each.with_object({}) do |store, hash| 187 | hash[store.label] = store.path 188 | end 189 | end 190 | 191 | def link_imapstore_accounts 192 | imapstores.each do |store| 193 | store.account = imapaccounts.find { |acct| acct.label == store.account } 194 | end 195 | end 196 | 197 | def link_group_channels 198 | groups.each do |group| 199 | group.channels.map! do |name| 200 | channels.find { |channel| name == channel.label } 201 | end 202 | end 203 | end 204 | 205 | def populate_imapstore_inboxes 206 | imapstores.each do |store| 207 | channel = channels.find do |channel| 208 | channel.account == store.account && channel.remotepath == 'INBOX' 209 | end 210 | if channel 211 | channel = channel.label 212 | else 213 | channel = channels.find do |channel| 214 | channel.account == store.account && channel.remotepath.empty? 215 | end.label + ':INBOX' 216 | end 217 | store.inbox = Inbox.new(store.account, 'INBOX', channel) 218 | end 219 | end 220 | 221 | def populate_channel_inboxes 222 | imapstores.each do |store| 223 | store.account.connections ||= {init: store.account.connect} 224 | channels.select { |c| c.remotestore == store }.each do |channel| 225 | patterns = channel.patterns || '*' 226 | pattern_list = patterns.split(/ (?![^"]*[^\s!]")/).map(&:unescape) 227 | channel.inboxes = [] 228 | pattern_list.each do |pattern| 229 | reduce_op = pattern.slice!(/^!/) ? :- : :+ 230 | channel.inboxes = channel.inboxes.send(reduce_op, store.account.connections[:init].list(channel.remotepath, pattern)).flatten 231 | end 232 | end 233 | end 234 | 235 | channels.each do |channel| 236 | channel.inboxes.map! do |inbox| 237 | folder = inbox.name.sub(channel.remotepath, '').sub(/^\//, '') 238 | Inbox.new(channel.account, 239 | inbox.name, 240 | (channel.label + (folder.empty? ? '' : ":#{folder}"))) 241 | end 242 | end 243 | end 244 | 245 | def populate_group_inboxes 246 | groups.each do |group| 247 | group.inboxes = [] 248 | group.channels.each { |channel| group.inboxes.push(channel.inboxes).flatten } 249 | end 250 | end 251 | end 252 | end 253 | end 254 | end 255 | -------------------------------------------------------------------------------- /lib/little_red_flag/mail_agent/mu.rb: -------------------------------------------------------------------------------- 1 | module LittleRedFlag 2 | module MailAgent 3 | class Mu 4 | include MailAgent 5 | 6 | def indexer? 7 | true 8 | end 9 | 10 | def command 11 | 'mu index' 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/little_red_flag/mail_agent/notmuch.rb: -------------------------------------------------------------------------------- 1 | module LittleRedFlag 2 | module MailAgent 3 | class Notmuch 4 | include MailAgent 5 | 6 | def indexer? 7 | true 8 | end 9 | 10 | def command 11 | 'notmuch new' 12 | end 13 | 14 | def ignore_pat 15 | /\.notmuch/ 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/little_red_flag/maildir.rb: -------------------------------------------------------------------------------- 1 | module LittleRedFlag 2 | # Methods for interacting with a Maildir mail store on the local filesystem. 3 | module Maildir 4 | class << self 5 | def of(file) 6 | validate_dir(file = File.dirname(File.expand_path(file))) 7 | dir = File.directory?(file) ? file : File.dirname(file) 8 | until Dir.glob(dir + '/{cur,new,tmp}').length == 3 9 | dir = File.dirname(dir) 10 | raise "#{file}: No user-readable maildir on this path" \ 11 | if !File.readable?(dir) || dir == '/' 12 | end 13 | dir 14 | end 15 | 16 | private 17 | 18 | def validate_dir(dir) 19 | raise ArgumentError, "#{dir}: no such directory" \ 20 | unless File.exist?(dir) 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/little_red_flag/ping.rb: -------------------------------------------------------------------------------- 1 | require 'socket' 2 | require 'timeout' 3 | require 'open3' 4 | 5 | module LittleRedFlag 6 | 7 | # Borrowed from the net/ping gem's Net::Ping::External class 8 | class Ping 9 | # The host to ping. In the case of Ping::HTTP, this is the URI. 10 | attr_accessor :host 11 | 12 | # The port to ping. This is set to the echo port (7) by default. The 13 | # Ping::HTTP class defaults to port 80. 14 | # 15 | attr_accessor :port 16 | 17 | # The maximum time a ping attempt is made. 18 | attr_accessor :timeout 19 | 20 | # If a ping fails, this value is set to the error that occurred which 21 | # caused it to fail. 22 | # 23 | attr_reader :exception 24 | 25 | # This value is set if a ping succeeds, but some other condition arose 26 | # during the ping attempt which merits warning, e.g a redirect in the 27 | # case of Ping::HTTP#ping. 28 | # 29 | attr_reader :warning 30 | 31 | # The number of seconds (returned as a Float) that it took to ping 32 | # the host. This is not a precise value, but rather a good estimate 33 | # since there is a small amount of internal calculation that is added 34 | # to the overall time. 35 | # 36 | attr_reader :duration 37 | 38 | # The default constructor for the Net::Ping class. Accepts an optional 39 | # +host+, +port+ and +timeout+. The port defaults to your echo port, or 40 | # 7 if that happens to be undefined. The default timeout is 5 seconds. 41 | # 42 | # The host, although optional in the constructor, must be specified at 43 | # some point before the Net::Ping#ping method is called, or else an 44 | # ArgumentError will be raised. 45 | # 46 | # Yields +self+ in block context. 47 | # 48 | # This class is not meant to be instantiated directly. It is strictly 49 | # meant as an interface for subclasses. 50 | # 51 | def initialize(host=nil, port=nil, timeout=5) 52 | @host = host 53 | @port = port || Socket.getservbyname('echo') || 7 54 | @timeout = timeout 55 | @exception = nil 56 | @warning = nil 57 | @duration = nil 58 | 59 | yield self if block_given? 60 | end 61 | 62 | # Pings the host using your system's ping utility and checks for any 63 | # errors or warnings. Returns true if successful, or false if not. 64 | # 65 | # If the ping failed then the Ping::External#exception method should 66 | # contain a string indicating what went wrong. If the ping succeeded then 67 | # the Ping::External#warning method may or may not contain a value. 68 | # 69 | def ping(host = @host, count = 1, timeout = @timeout) 70 | raise ArgumentError, 'no host specified' unless host 71 | raise "Count must be an integer" unless count.is_a? Integer 72 | raise "Timeout must be a number" unless timeout.is_a? Numeric 73 | 74 | pcmd = ['ping'] 75 | bool = false 76 | 77 | case RbConfig::CONFIG['host_os'] 78 | when /linux/i 79 | pcmd += ['-c', count.to_s, '-W', timeout.to_s, host] 80 | when /aix/i 81 | pcmd += ['-c', count.to_s, '-w', timeout.to_s, host] 82 | when /bsd|osx|mach|darwin/i 83 | pcmd += ['-c', count.to_s, '-t', timeout.to_s, host] 84 | when /solaris|sunos/i 85 | pcmd += [host, timeout.to_s] 86 | when /hpux/i 87 | pcmd += [host, "-n#{count.to_s}", '-m', timeout.to_s] 88 | when /win32|windows|msdos|mswin|cygwin|mingw/i 89 | pcmd += ['-n', count.to_s, '-w', (timeout * 1000).to_s, host] 90 | else 91 | pcmd += [host] 92 | end 93 | 94 | start_time = Time.now 95 | 96 | begin 97 | err = nil 98 | 99 | Open3.popen3(*pcmd) do |stdin, stdout, stderr, thread| 100 | stdin.close 101 | err = stderr.gets # Can't chomp yet, might be nil 102 | 103 | case thread.value.exitstatus 104 | when 0 105 | info = stdout.read 106 | if info =~ /unreachable/ix # Windows 107 | bool = false 108 | @exception = "host unreachable" 109 | else 110 | bool = true # Success, at least one response. 111 | end 112 | 113 | if err & err =~ /warning/i 114 | @warning = err.chomp 115 | end 116 | when 2 117 | bool = false # Transmission successful, no response. 118 | @exception = err.chomp if err 119 | else 120 | bool = false # An error occurred 121 | if err 122 | @exception = err.chomp 123 | else 124 | stdout.each_line do |line| 125 | if line =~ /(timed out|could not find host|packet loss)/i 126 | @exception = line.chomp 127 | break 128 | end 129 | end 130 | end 131 | end 132 | end 133 | rescue Exception => error 134 | @exception = error.message 135 | end 136 | 137 | # There is no duration if the ping failed 138 | @duration = Time.now - start_time if bool 139 | 140 | bool 141 | end 142 | 143 | def ping6(host = @host, count = 1, timeout = @timeout) 144 | 145 | raise "Count must be an integer" unless count.is_a? Integer 146 | raise "Timeout must be a number" unless timeout.is_a? Numeric 147 | 148 | pcmd = ['ping6'] 149 | bool = false 150 | 151 | case RbConfig::CONFIG['host_os'] 152 | when /linux/i 153 | pcmd += ['-c', count.to_s, '-W', timeout.to_s, host] 154 | when /aix/i 155 | pcmd += ['-c', count.to_s, '-w', timeout.to_s, host] 156 | when /bsd|osx|mach|darwin/i 157 | pcmd += ['-c', count.to_s, '-t', timeout.to_s, host] 158 | when /solaris|sunos/i 159 | pcmd += [host, timeout.to_s] 160 | when /hpux/i 161 | pcmd += [host, "-n#{count.to_s}", '-m', timeout.to_s] 162 | when /win32|windows|msdos|mswin|cygwin|mingw/i 163 | pcmd += ['-n', count.to_s, '-w', (timeout * 1000).to_s, host] 164 | else 165 | pcmd += [host] 166 | end 167 | 168 | start_time = Time.now 169 | 170 | begin 171 | err = nil 172 | 173 | Open3.popen3(*pcmd) do |stdin, stdout, stderr, thread| 174 | stdin.close 175 | err = stderr.gets # Can't chomp yet, might be nil 176 | 177 | case thread.value.exitstatus 178 | when 0 179 | info = stdout.read 180 | if info =~ /unreachable/ix # Windows 181 | bool = false 182 | @exception = "host unreachable" 183 | else 184 | bool = true # Success, at least one response. 185 | end 186 | 187 | if err & err =~ /warning/i 188 | @warning = err.chomp 189 | end 190 | when 2 191 | bool = false # Transmission successful, no response. 192 | @exception = err.chomp if err 193 | else 194 | bool = false # An error occurred 195 | if err 196 | @exception = err.chomp 197 | else 198 | stdout.each_line do |line| 199 | if line =~ /(timed out|could not find host|packet loss)/i 200 | @exception = line.chomp 201 | break 202 | end 203 | end 204 | end 205 | end 206 | end 207 | rescue Exception => error 208 | @exception = error.message 209 | end 210 | 211 | # There is no duration if the ping failed 212 | @duration = Time.now - start_time if bool 213 | 214 | bool 215 | end 216 | 217 | alias ping? ping 218 | alias pingecho ping 219 | end 220 | end 221 | -------------------------------------------------------------------------------- /lib/little_red_flag/version.rb: -------------------------------------------------------------------------------- 1 | module LittleRedFlag 2 | VERSION = '0.1.6' 3 | end 4 | -------------------------------------------------------------------------------- /little_red_flag.gemspec: -------------------------------------------------------------------------------- 1 | require File.expand_path('../lib/little_red_flag/version', __FILE__) 2 | 3 | Gem::Specification.new do |s| 4 | s.name = 'little_red_flag' 5 | s.version = LittleRedFlag::VERSION 6 | s.author = 'Ryan Lue' 7 | s.email = 'ryan.lue@gmail.com' 8 | 9 | s.summary = 'Sync IMAP mail to your machine. Automatically, instantly, all ' \ 10 | 'the time.' 11 | s.description = 'isync (mbsync) is a command-line tool for synchronizing ' \ 12 | 'IMAP and local Maildir mailboxes. It’s faster and stabler than the next ' \ 13 | 'most popular alternative (OfflineIMAP), but still must be invoked ' \ 14 | 'manually. Little Red Flag keeps an eye on your mailboxes and runs the ' \ 15 | 'appropriate `mbsync` command anytime changes occur, whether locally or ' \ 16 | 'remotely. It also detects the presence of `mu` / `notmuch` mail ' \ 17 | 'indexers, and re-indexes after each sync.' 18 | s.homepage = 'http://github.com/rlue/little_red_flag' 19 | s.license = 'MIT' 20 | 21 | s.files = `git ls-files -z`.split("\x0").reject do |f| 22 | f.match(%r{^(spec/|\.\w)}) 23 | end 24 | s.executables = ['littleredflag'] 25 | s.require_paths = ['lib'] 26 | s.required_ruby_version = '>= 2.2.5' 27 | s.add_runtime_dependency 'listen', '~> 3.1' 28 | s.add_runtime_dependency 'sys-proctable', '~> 1.1' 29 | end 30 | --------------------------------------------------------------------------------