├── .github ├── dependabot.yml └── workflows │ └── ruby.yml ├── .gitignore ├── .rspec ├── .travis.yml ├── Gemfile ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── exe └── popper ├── init_script └── cent7 │ └── etc │ └── systemd │ └── system │ └── popper.service ├── lib ├── popper.rb └── popper │ ├── action.rb │ ├── action │ ├── base.rb │ ├── exec_cmd.rb │ ├── ghe.rb │ ├── git.rb │ ├── slack.rb │ └── webhook.rb │ ├── cli.rb │ ├── config.rb │ ├── init.rb │ ├── mail_account.rb │ └── version.rb ├── popper.gemspec └── spec ├── fixture ├── attach1.jpg ├── attach2.jpg ├── popper_config_test.conf ├── popper_include.conf ├── popper_match_rules.conf └── popper_run.conf ├── lib ├── config_spec.rb ├── lib │ └── popper │ │ └── action │ │ ├── exec_cmd_spec.rb │ │ └── webhook_spec.rb └── mail_account_spec.rb └── spec_helper.rb /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "bundler" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/ruby.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | # This workflow will download a prebuilt Ruby version, install dependencies and run tests with Rake 6 | # For more information see: https://github.com/marketplace/actions/setup-ruby-jruby-and-truffleruby 7 | 8 | name: Ruby 9 | 10 | on: 11 | push: 12 | branches: [ "master" ] 13 | pull_request: 14 | branches: [ "master" ] 15 | 16 | permissions: 17 | contents: read 18 | 19 | jobs: 20 | test: 21 | 22 | runs-on: ubuntu-latest 23 | strategy: 24 | matrix: 25 | ruby-version: ['3.2', '3.3'] 26 | 27 | steps: 28 | - uses: actions/checkout@v4 29 | - name: Set up Ruby 30 | uses: ruby/setup-ruby@v1 31 | with: 32 | ruby-version: ${{ matrix.ruby-version }} 33 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 34 | - name: Run tests 35 | run: bundle exec rake 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | /vendor 11 | /*.yaml 12 | *.sample 13 | /user_datas 14 | .ruby-version 15 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.4.2 4 | addons: 5 | code_climate: 6 | repo_token: c969d1896316cbd26ab70d3b7fef4f4e168df83b849fa7f16a1885569c86f29c 7 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | # Specify your gem's dependencies in popper.gemspec 3 | gemspec 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # popper 2 | 3 | [![Build Status](https://travis-ci.org/pyama86/popper.svg)](https://travis-ci.org/pyama86/popper) 4 | [![Code Climate](https://codeclimate.com/github/pyama86/popper/badges/gpa.svg)](https://codeclimate.com/github/pyama86/popper) 5 | [![Test Coverage](https://codeclimate.com/github/pyama86/popper/badges/coverage.svg)](https://codeclimate.com/github/pyama86/popper/coverage) 6 | 7 | To post a variety of services by analyzing the email 8 | * slack notification 9 | * create issue to github.com or ghe 10 | * exec arbitrary commands 11 | 12 | # install 13 | $ gem install popper 14 | 15 | # usage 16 | ``` 17 | # create /etc/popper.conf 18 | $ popper init 19 | 20 | # edit popper.conf 21 | $ vi /etc/popper.conf 22 | 23 | # print config 24 | $ popper print 25 | 26 | $ popper --daemon --config /etc/popper.conf --log /var/log/popper.log --pidfile /var/run/popper/popper.pid 27 | ``` 28 | systmd service config: https://github.com/pyama86/popper/tree/master/init_script/cent7/etc/systemd/system/popper.service 29 | 30 | # configure(toml) 31 | ## ~/popper/popper.conf 32 | ```toml 33 | include = ["/etc/popper/*.conf"] 34 | interval = 60 # fetch interbal default:60 35 | 36 | [default.condition] 37 | 38 | subject = ["^(?!.*Re:).+$"] 39 | 40 | [default.action.slack] 41 | 42 | webhook_url = "webhook_url" 43 | user = "slack" 44 | channel = "#default_channel" 45 | message = "default message" 46 | 47 | # .login 48 | [example.login] 49 | 50 | server = "example.com" 51 | user = "example@example.com" 52 | password = "password" 53 | port = 110(default) 54 | ssl = false 55 | delete_after = false 56 | 57 | # .default.condition 58 | [example.default.condition] 59 | subject = [".*default.*"] 60 | 61 | # .default.action. 62 | [example.default.action.slack] 63 | channel = "#account default" 64 | 65 | # .rules..condition 66 | [example.rules.normal_log.condition] 67 | 68 | subject = [".*Webmailer Exception.*"] 69 | 70 | # .rules..action. 71 | [example.rules.normal_log.action.slack] 72 | 73 | channel = "#channel" 74 | mentions = ["@user"] 75 | message = "webmailer error mail" 76 | 77 | [example.rules.normal_log.action.git] 78 | repo = "example/fuu" 79 | labels = "label1,label2" 80 | 81 | [example.rules.normal_log.action.exec_cmd] 82 | cmd = "/path/to/other_command.rb" 83 | 84 | [example2.login] 85 | user = "example2@example.com" 86 | ... 87 | ``` 88 | 89 | # option 90 | ``` 91 | -c, [--config=CONFIG] 92 | -l, [--log=LOG] 93 | -d, [--daemon], [--no-daemon] 94 | -p, [--pidfile=PIDFILE] 95 | ``` 96 | 97 | # author 98 | * pyama 99 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | ENV['POPPER_TEST'] = "1" 5 | RSpec::Core::RakeTask.new("spec") 6 | task :default => :spec 7 | 8 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "popper" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start 15 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | 5 | bundle install 6 | 7 | # Do any other automated setup that you need to do here 8 | -------------------------------------------------------------------------------- /exe/popper: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "popper" 4 | Popper::CLI.start 5 | -------------------------------------------------------------------------------- /init_script/cent7/etc/systemd/system/popper.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Popper 3 | Requires=network.target 4 | After=network.target 5 | 6 | [Service] 7 | Type=forking 8 | User=root 9 | 10 | Restart=always 11 | RestartSec=120 12 | 13 | ExecStart=/usr/local/bin/popper --daemon --config /etc/popper.conf --log /var/log/popper.log --pidfile /var/run/popper/popper.pid 14 | PIDFile=/var/run/popper/popper.pid 15 | 16 | [Install] 17 | WantedBy=multi-user.target 18 | -------------------------------------------------------------------------------- /lib/popper.rb: -------------------------------------------------------------------------------- 1 | require 'pp' 2 | require 'popper/version' 3 | require 'popper/cli' 4 | require 'popper/mail_account' 5 | require 'popper/config' 6 | require 'popper/action' 7 | require 'popper/init' 8 | 9 | module Popper 10 | def self.init_logger(options, stdout = nil) 11 | log_path = options[:log] || '/var/log/popper.log' 12 | log_path = STDOUT if ENV['POPPER_TEST'] || stdout 13 | @_logger = Logger.new(log_path) 14 | rescue StandardError => e 15 | puts e 16 | end 17 | 18 | def self.log 19 | @_logger 20 | end 21 | end 22 | 23 | # ref: https://github.com/ruby/net-pop/issues/27 24 | module Net 25 | class POP3Command 26 | def list 27 | critical do 28 | getok 'LIST' 29 | list = [] 30 | @socket.each_list_item do |line| 31 | if line.split(' ').size != 2 32 | num, uid = line.split(' ')[2..3] 33 | list.push [num.to_i, uid.to_i] 34 | end 35 | m = /\A(\d+)[ \t]+(\d+)/.match(line) or 36 | raise POPBadResponse, "bad response: #{line}" 37 | list.push [m[1].to_i, m[2].to_i] 38 | end 39 | return list 40 | end 41 | end 42 | 43 | def uidl(num = nil) 44 | if num 45 | res = check_response(critical { get_response('UIDL %d', num) }) 46 | res.split(/ /)[1] 47 | else 48 | critical do 49 | getok('UIDL') 50 | table = {} 51 | @socket.each_list_item do |line| 52 | if line.split(' ').size == 4 53 | num, uid = line.split(' ')[2..3] 54 | table[num.to_i] = uid 55 | end 56 | num, uid = line.split(' ') 57 | table[num.to_i] = uid 58 | end 59 | return table 60 | end 61 | end 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/popper/action.rb: -------------------------------------------------------------------------------- 1 | module Popper::Action 2 | autoload :Base, "popper/action/base" 3 | autoload :Slack, "popper/action/slack" 4 | autoload :Ghe, "popper/action/ghe" 5 | autoload :Git, "popper/action/git" 6 | autoload :ExecCmd, "popper/action/exec_cmd" 7 | autoload :Webhook, "popper/action/webhook" 8 | end 9 | -------------------------------------------------------------------------------- /lib/popper/action/base.rb: -------------------------------------------------------------------------------- 1 | module Popper::Action 2 | class Base 3 | @next_action = nil 4 | @action_config = nil 5 | 6 | def self.run(config, mail, params={}) 7 | @action_config = config.send(action_name) if config.respond_to?(action_name) 8 | begin 9 | Popper.log.info "run action #{action_name}" 10 | params = task(mail, params) 11 | Popper.log.info "exit action #{action_name}" 12 | rescue => e 13 | Popper.log.warn e 14 | Popper.log.warn e.backtrace 15 | end if do_action? 16 | next_run(config, mail, params) 17 | end 18 | 19 | def self.next_action(action=nil) 20 | @next_action = action if action 21 | @next_action 22 | end 23 | 24 | def self.next_run(config, mail, params={}) 25 | @next_action.run(config, mail, params) if @next_action 26 | end 27 | 28 | def self.do_action? 29 | @action_config && check_params 30 | end 31 | 32 | def self.action_name 33 | self.name.split('::').last.downcase.to_sym 34 | end 35 | 36 | 37 | 38 | def self.check_params; end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/popper/action/exec_cmd.rb: -------------------------------------------------------------------------------- 1 | require 'bundler' 2 | require 'shellwords' 3 | require 'tempfile' 4 | module Popper::Action 5 | class ExecCmd < Base 6 | def self.task(mail, params = {}) 7 | unless mail.attachments.empty? 8 | tmps = mail.attachments.map do |a| 9 | ::Tempfile.open(a.filename) do |f| 10 | f.write a.body.decoded 11 | f 12 | end 13 | end 14 | end 15 | cmd = "#{@action_config.cmd} #{Shellwords.escape(mail.subject)} #{Shellwords.escape(mail.utf_body)} #{Shellwords.escape(mail.from.join(';'))} #{Shellwords.escape(mail.to.join(';'))}" 16 | cmd += " #{tmps.map { |t| Shellwords.escape(t.path) }.join(' ')}" if tmps && !tmps.empty? 17 | ::Bundler.with_clean_env do 18 | system(cmd) 19 | end 20 | params 21 | end 22 | 23 | def self.check_params 24 | @action_config.respond_to?(:cmd) 25 | end 26 | 27 | def self.action_name 28 | :exec_cmd 29 | end 30 | 31 | next_action(Git) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/popper/action/ghe.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | require 'octokit' 3 | module Popper::Action 4 | class Ghe < Git 5 | def self.octkit 6 | Octokit.reset! 7 | Octokit.configure do |c| 8 | c.web_endpoint = @action_config.url 9 | c.api_endpoint = File.join(@action_config.url, "api/v3") 10 | end 11 | Octokit::Client.new(:access_token => @action_config.token) 12 | end 13 | 14 | def self.check_params 15 | @action_config.respond_to?(:url) && super 16 | end 17 | 18 | next_action(Slack) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/popper/action/git.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | require 'octokit' 3 | module Popper::Action 4 | class Git < Base 5 | def self.task(mail, params={}) 6 | issue_options = {} 7 | issue_options[:labels] = @action_config.labels if @action_config.labels 8 | 9 | url = octkit.create_issue( 10 | @action_config.repo, 11 | mail.subject, 12 | mail.utf_body, 13 | issue_options 14 | ) 15 | params["#{action_name}_url".to_sym] = url[:html_url] if url 16 | params 17 | end 18 | 19 | def self.octkit 20 | Octokit.reset! 21 | Octokit::Client.new(:access_token => @action_config.token) 22 | end 23 | 24 | def self.check_params 25 | @action_config.respond_to?(:repo) && 26 | @action_config.respond_to?(:token) 27 | end 28 | 29 | next_action(Ghe) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/popper/action/slack.rb: -------------------------------------------------------------------------------- 1 | require 'slack-notifier' 2 | module Popper::Action 3 | class Slack < Base 4 | def self.task(mail, params={}) 5 | notifier = ::Slack::Notifier.new( 6 | @action_config.webhook_url, 7 | channel: @action_config.channel, 8 | username: @action_config.user || 'popper', 9 | link_names: 1 10 | ) 11 | 12 | note = { 13 | pretext: mail.date.to_s, 14 | title: mail.subject, 15 | color: "good" 16 | } 17 | note[:text] = mail_body(mail.utf_body) if @action_config.use_body 18 | 19 | body = @action_config.message || "popper mail notification" 20 | body += " #{@action_config.mentions.join(" ")}" if @action_config.mentions 21 | %w( 22 | git 23 | ghe 24 | ).each do |name| 25 | body += " #{name}:#{params[(name + '_url').to_sym]}" if params[(name + '_url').to_sym] 26 | end 27 | 28 | notifier.ping body, attachments: [note] 29 | end 30 | 31 | def self.check_params 32 | @action_config.respond_to?(:channel) && 33 | @action_config.respond_to?(:webhook_url) 34 | end 35 | 36 | def self.mail_body(body) 37 | if @action_config.use_body.kind_of?(Integer) && body.lines.length > @action_config.use_body 38 | return body.lines[0, @action_config.use_body].push('--- snip ---').join 39 | end 40 | 41 | body 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/popper/action/webhook.rb: -------------------------------------------------------------------------------- 1 | module Popper::Action 2 | class Webhook < Base 3 | def self.task(mail, params = {}) 4 | post!(@action_config.webhook_url, mail.subject, mail.utf_body) 5 | 6 | params 7 | end 8 | 9 | def self.check_params 10 | @action_config.respond_to?(:webhook_url) 11 | end 12 | 13 | def self.post!(url, subject, body) 14 | request_body = { subject: subject, body: body }.to_json 15 | 16 | Faraday.post( 17 | url, 18 | request_body, 19 | "Content-Type" => "application/json" 20 | ) 21 | end 22 | 23 | next_action(ExecCmd) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/popper/cli.rb: -------------------------------------------------------------------------------- 1 | require 'popper' 2 | require 'thor' 3 | 4 | module Popper 5 | class CLI < Thor 6 | class_option :config, type: :string, aliases: '-c' 7 | class_option :log, type: :string, aliases: '-l' 8 | class_option :daemon, type: :boolean, aliases: '-d' 9 | class_option :pidfile, type: :string, aliases: '-p' 10 | default_task :pop 11 | desc "pop", "from pop3" 12 | def pop 13 | if(options[:daemon]) 14 | Popper.init_logger(options) 15 | Process.daemon 16 | open(options[:pidfile] || "/var/run/popper.pid" , 'w') {|f| f << Process.pid} 17 | else 18 | Popper.init_logger(options, true) 19 | end 20 | 21 | Popper.load_config(options) 22 | 23 | accounts = Popper.configure.accounts.map do |account| 24 | MailAccount.new(account) 25 | end.compact 26 | 27 | while true 28 | accounts.each(&:run) 29 | sleep(Popper.configure.interval) 30 | end 31 | 32 | rescue => e 33 | Popper.log.fatal(e) 34 | Popper.log.fatal(e.backtrace) 35 | end 36 | 37 | class_option :config, type: :string, aliases: '-c' 38 | desc "print", "print configure" 39 | def print 40 | Popper.load_config(options) 41 | Popper.configure.accounts.each do |account| 42 | print_config(account) 43 | end 44 | end 45 | 46 | desc "init", "create home dir" 47 | def init 48 | Popper::Init.run(options) 49 | rescue => e 50 | puts e 51 | puts e.backtrace 52 | end 53 | 54 | map %w[--version -v] => :__print_version 55 | desc "--version, -v", "print the version" 56 | def __print_version 57 | puts "Popper version:#{Popper::VERSION}" 58 | end 59 | 60 | no_commands do 61 | def print_config(config) 62 | last_rule = nil 63 | puts config.name 64 | 65 | config.rule_with_conditions_each do |rule,mail_header,conditions| 66 | if rule != last_rule 67 | puts " "*1 + "rule[#{rule}]" 68 | puts " "*2 + "actions" 69 | print_action(config, rule) 70 | end 71 | 72 | puts " "*2 + "header[#{mail_header}]" 73 | puts " "*3 + "#{conditions}" 74 | last_rule = rule 75 | end 76 | end 77 | 78 | def print_action(config, rule) 79 | config.action_by_rule(rule).each_pair do |action,params| 80 | puts " "*3 + "#{action}" 81 | params.each_pair do |k,v| 82 | puts " "*4 + "#{k} #{v}" 83 | end 84 | end 85 | end 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /lib/popper/config.rb: -------------------------------------------------------------------------------- 1 | require 'toml' 2 | require 'ostruct' 3 | require 'logger' 4 | module Popper 5 | class Config 6 | attr_reader :default, :accounts, :interval 7 | 8 | def initialize(config_path) 9 | raise "configure not fond #{config_path}" unless File.exist?(config_path) 10 | 11 | config = read_file(config_path) 12 | 13 | @interval = config.key?('interval') ? config['interval'].to_i : 60 14 | @default = config['default'] if config['default'] 15 | @accounts = config.select { |_k, v| v.is_a?(Hash) && v.key?('login') }.map do |account| 16 | _account = AccountAttributes.new(account[1]) 17 | _account.name = account[0] 18 | _account 19 | end 20 | end 21 | 22 | def read_file(file) 23 | config = TOML.load_file(file) 24 | if config.key?('include') 25 | content = config['include'].map { |p| Dir.glob(p).map { |f| File.read(f) } }.join("\n") 26 | config.deep_merge!(TOML::Parser.new(content).parsed) 27 | end 28 | config 29 | end 30 | 31 | %w[ 32 | condition 33 | action 34 | ].each do |name| 35 | define_method("default_#{name}") do 36 | default[name] 37 | rescue StandardError 38 | {} 39 | end 40 | end 41 | end 42 | 43 | class AccountAttributes < OpenStruct 44 | def initialize(hash = nil) 45 | super 46 | @table = {} 47 | @hash = hash 48 | 49 | if hash 50 | hash.each do |k, v| 51 | @table[k.to_sym] = (v.is_a?(Hash) ? self.class.new(v) : v) 52 | end 53 | end 54 | end 55 | 56 | def to_h 57 | @hash 58 | end 59 | 60 | [ 61 | %w[select all?], 62 | %w[each each] 63 | ].each do |arr| 64 | define_method("rule_with_conditions_#{arr[0]}") do |&blk| 65 | @hash['rules'].keys.send(arr[0]) do |rule| 66 | condition_by_rule(rule).to_h.send(arr[1]) do |mail_header, conditions| 67 | blk.call(rule, mail_header, conditions) 68 | end 69 | end 70 | end 71 | end 72 | 73 | %w[ 74 | condition 75 | action 76 | ].each do |name| 77 | define_method("account_default_#{name}") do 78 | @hash['default'][name] 79 | rescue StandardError 80 | {} 81 | end 82 | 83 | # merge default and account default 84 | define_method("#{name}_by_rule") do |rule| 85 | hash = Popper.configure.send("default_#{name}") 86 | hash = hash.deep_merge(send("account_default_#{name}")) if send("account_default_#{name}") 87 | hash = hash.deep_merge(rule_by_name(rule)[name]) if rule_by_name(rule).key?(name) 88 | 89 | # replace body to utf_body 90 | AccountAttributes.new(Hash[hash.map { |k, v| [k.to_s.gsub(/^body$/, 'utf_body').to_sym, v] }]) 91 | end 92 | end 93 | 94 | def rule_by_name(name) 95 | @hash['rules'][name] 96 | rescue StandardError 97 | {} 98 | end 99 | end 100 | 101 | def self.load_config(options) 102 | config_path = options[:config] || '/etc/popper.conf' 103 | @_config = Config.new(config_path) 104 | end 105 | 106 | def self.configure 107 | @_config 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /lib/popper/init.rb: -------------------------------------------------------------------------------- 1 | module Popper 2 | class Init 3 | def self.run(options) 4 | filename = options[:config] || "/etc/popper.conf" 5 | unless FileTest.exist?(filename) 6 | open(filename,"w") do |e| 7 | e.puts sample_config 8 | end 9 | puts "create sample config #{filename}" 10 | end 11 | end 12 | 13 | def self.sample_config 14 | <<-EOS 15 | interval = 60 # fetch interbal default:60 16 | 17 | [default.condition] 18 | subject = ["^(?!.*Re:).+$"] 19 | 20 | [default.action.slack] 21 | webhook_url = "webhook_url" 22 | user = "slack" 23 | channel = "#default_channel" 24 | message = "default message" 25 | 26 | [example.login] 27 | server = "mail.examplejp" 28 | user = "examplle_user" 29 | password = "examplle_pass" 30 | 31 | [example.default.condition] 32 | subject = [".*default.*"] 33 | 34 | [example.rules.normal_log.condition] 35 | subject = ".*example.*" 36 | body = ".*example.*" 37 | 38 | [example.rules.normal_log.action.slack] 39 | channel = "#test" 40 | mentions = ["@test"] 41 | message = "test message" 42 | 43 | [example.rules.normal_log.action.git] 44 | repo = "test/example" 45 | 46 | [example.rules.normal_log.action.ghe] 47 | repo = "test/example" 48 | EOS 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/popper/mail_account.rb: -------------------------------------------------------------------------------- 1 | require 'net/pop' 2 | require 'mail' 3 | require 'kconv' 4 | 5 | module Popper 6 | class MailAccount 7 | attr_accessor :config, :current_list, :complete_list 8 | 9 | def initialize(config) 10 | @config = config 11 | end 12 | 13 | def run 14 | session_start do |conn| 15 | @current_list = conn.mails.map(&:uidl) 16 | @complete_list ||= @current_list 17 | pop(conn) 18 | end 19 | rescue StandardError => e 20 | Popper.log.warn e 21 | end 22 | 23 | def pop(conn) 24 | done_uidls = [] 25 | error_uidls = [] 26 | 27 | Popper.log.info "start popper #{config.name}" 28 | 29 | process_uidl_list(conn).each do |m| 30 | uidl = m.uidl 31 | Popper.log.info "pop mail:#{uidl}" 32 | done_uidls << check_and_action(m) 33 | m.delete if config.login.respond_to?(:delete_after) && config.login.delete_after 34 | rescue Net::POPError => e 35 | @complete_list += done_uidls 36 | Popper.log.warn 'pop err write uidl' 37 | return 38 | rescue StandardError => e 39 | error_uidls << m.uidl 40 | Popper.log.warn e 41 | end 42 | 43 | @complete_list = @current_list - error_uidls 44 | Popper.log.info "success popper #{config.name} current_list:#{@current_list.size} complete_list:#{@complete_list.size}" 45 | end 46 | 47 | def check_and_action(m) 48 | mail = EncodeMail.new(m.mail) 49 | Popper.log.info "check mail:#{mail.date} #{mail.subject}" 50 | match_rules?(mail).each do |rule| 51 | Popper.log.info "do action:#{mail.subject}" 52 | Popper::Action::Webhook.run(config.action_by_rule(rule), mail) if config.action_by_rule(rule) 53 | end 54 | 55 | m.uidl 56 | end 57 | 58 | def session_start(&block) 59 | pop = Net::POP3.new(config.login.server, config.login.port || 110) 60 | pop = set_pop_option(pop) 61 | 62 | pop.start( 63 | config.login.user, 64 | config.login.password 65 | ) do |conn| 66 | Popper.log.info "connect server #{config.name}" 67 | block.call(conn) 68 | Popper.log.info "disconnect server #{config.name}" 69 | end 70 | end 71 | 72 | def set_pop_option(pop) 73 | pop.enable_ssl if config.login.respond_to?(:ssl) && config.login.ssl 74 | %w[ 75 | open_timeout 76 | read_timeout 77 | ].each do |m| 78 | pop.instance_variable_set("@#{m}", ENV['POP_TIMEOUT'] || 120) 79 | end 80 | pop 81 | end 82 | 83 | def process_uidl_list(conn) 84 | uidl_list = @current_list - @complete_list 85 | conn.mails.select { |_m| uidl_list.include?(_m.uidl) } 86 | end 87 | 88 | def match_rules?(mail) 89 | config.rule_with_conditions_select do |_rule, mail_header, conditions| 90 | conditions.all? do |condition| 91 | mail.respond_to?(mail_header) && mail.send(mail_header).to_s.match(/#{condition}/) 92 | end 93 | end 94 | end 95 | end 96 | end 97 | 98 | class ::Hash 99 | def deep_merge(second) 100 | merger = proc { |_key, v1, v2| 101 | if v1.is_a?(Hash) && v2.is_a?(Hash) 102 | v1.merge(v2, &merger) 103 | elsif v1.is_a?(Array) && v2.is_a?(Array) 104 | v1 | v2 105 | else 106 | [:undefined, nil, :nil].include?(v2) ? v1 : v2 107 | end 108 | } 109 | merge(second.to_h, &merger) 110 | end 111 | 112 | def deep_merge!(second) 113 | merge!(deep_merge(second)) 114 | end 115 | end 116 | 117 | class EncodeMail < Mail::Message 118 | def subject 119 | Kconv.toutf8(self[:Subject].value) if self[:Subject] 120 | end 121 | 122 | def utf_body 123 | if multipart? 124 | parts.map do |part| 125 | part.body.decoded.encode('UTF-8', charset, undef: :replace, invalid: :replace, replace: '') unless part.attachment? 126 | end.join 127 | else 128 | body.decoded.encode('UTF-8', charset, undef: :replace, invalid: :replace, replace: '') 129 | end 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /lib/popper/version.rb: -------------------------------------------------------------------------------- 1 | module Popper 2 | VERSION = '0.6.5' 3 | end 4 | -------------------------------------------------------------------------------- /popper.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('lib', __dir__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require 'popper/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = 'popper' 7 | spec.version = Popper::VERSION 8 | spec.authors = ['pyama86'] 9 | spec.email = ['pyama@pepabo.com'] 10 | 11 | spec.summary = 'email notification tool' 12 | spec.description = 'email notification tool' 13 | spec.homepage = 'http://ten-snapon.com' 14 | spec.license = 'MIT' 15 | 16 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 17 | spec.bindir = 'exe' 18 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 19 | spec.require_paths = ['lib'] 20 | 21 | spec.add_dependency 'bundler' 22 | spec.add_dependency 'faraday' 23 | spec.add_dependency 'mail' 24 | spec.add_dependency 'net-pop' 25 | spec.add_dependency 'net-smtp' 26 | spec.add_dependency 'octokit' 27 | spec.add_dependency 'slack-notifier' 28 | spec.add_dependency 'thor' 29 | spec.add_dependency 'toml' 30 | spec.add_development_dependency 'rake', '~> 13.0' 31 | spec.add_development_dependency 'rspec' 32 | end 33 | -------------------------------------------------------------------------------- /spec/fixture/attach1.jpg: -------------------------------------------------------------------------------- 1 | not jpg 2 | -------------------------------------------------------------------------------- /spec/fixture/attach2.jpg: -------------------------------------------------------------------------------- 1 | not jpg 2 | -------------------------------------------------------------------------------- /spec/fixture/popper_config_test.conf: -------------------------------------------------------------------------------- 1 | interval = 60 # fetch interbal default:60 2 | 3 | [default.condition] 4 | subject = ["^(?!.*Re:).+$"] 5 | 6 | [default.action.ghe] 7 | token = "test_token" 8 | 9 | [example_1.login] 10 | server = "mail.examplejp" 11 | user = "examplle_user" 12 | password = "examplle_pass" 13 | 14 | [example_1.default.condition] 15 | body = [".*account_default_body.*"] 16 | 17 | [example_1.default.action.ghe] 18 | url = "https://ghe.example.com" 19 | 20 | 21 | [example_1.rules.test_rule.condition] 22 | subject = [".*account_rule_subject.*"] 23 | 24 | [example_1.rules.test_rule.action.ghe] 25 | repo = "example/rule" 26 | 27 | 28 | [example_2.login] 29 | server = "mail.examplejp" 30 | user = "examplle_user" 31 | password = "examplle_pass" 32 | 33 | [example_2.default.condition] 34 | body = [".*account_default_body_2.*"] 35 | 36 | [example_2.default.action.ghe] 37 | url = "https://2.ghe.example.com" 38 | 39 | [example_2.rules.test_rule.condition] 40 | subject = [".*account_rule_subject_2.*"] 41 | 42 | [example_2.rules.test_rule.action.ghe] 43 | repo = "example/rule_2" 44 | -------------------------------------------------------------------------------- /spec/fixture/popper_include.conf: -------------------------------------------------------------------------------- 1 | include = ["spec/fixture/popper_config_test.conf"] 2 | -------------------------------------------------------------------------------- /spec/fixture/popper_match_rules.conf: -------------------------------------------------------------------------------- 1 | [example.login] 2 | server = "mail.examplejp" 3 | user = "examplle_user" 4 | password = "examplle_pass" 5 | 6 | [default.condition] 7 | subject = [".*ok.*", "^(?!.*Re:).+$"] 8 | 9 | [example.rules.test_rule.condition] 10 | body = [".*ok.*"] 11 | 12 | [example.rules.multiple_match1.condition] 13 | body = [".*multiple.*"] 14 | 15 | [example.rules.multiple_match2.condition] 16 | body = [".*match.*"] 17 | -------------------------------------------------------------------------------- /spec/fixture/popper_run.conf: -------------------------------------------------------------------------------- 1 | # enable default 2 | [default.condition] 3 | subject = [".*default_condition"] 4 | 5 | [default.action.slack] 6 | channel = "#default_action_slack" 7 | message = "default_action_slack" 8 | webhook_url = "https://default.action.slack.com" 9 | user = "default_action_slack" 10 | 11 | [example.login] 12 | server = "mail.examplejp" 13 | user = "examplle_user" 14 | password = "examplle_pass" 15 | delete_after = true 16 | # enable account default 17 | [example.default.condition] 18 | body = [".*account_default_condition.*"] 19 | 20 | [example.default.action.git] 21 | token = "account_default_action" 22 | repo = "example/account_rule_action_git" 23 | labels = "first_gh_label,second_gh_label" 24 | 25 | [example.rules.foo.condition] 26 | subject = [".*account_rule_condition_subject.*"] 27 | body = [".*account_rule_condition_body.*"] 28 | 29 | [example.rules.foo.action.ghe] 30 | token = "account_rule_action_ghe" 31 | url = "https://account_rule_action_ghe" 32 | repo = "example/account_rule_action_ghe" 33 | labels = "first_ghe_label,second_ghe_label" 34 | 35 | [example.rules.foo.action.exec_cmd] 36 | cmd = "test_command" 37 | 38 | [example.rules.foo.action.webhook] 39 | webhook_url = "https://localhost:12345/webhook/event" 40 | -------------------------------------------------------------------------------- /spec/lib/config_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'slack-notifier' 3 | require 'octokit' 4 | describe Popper::Config do 5 | 6 | shared_examples_for 'check_config' do 7 | context 'global' do 8 | it { expect(Popper.configure.interval).to eq 60 } 9 | end 10 | 11 | context 'condition' do 12 | it { expect(Popper.configure.accounts.first.condition_by_rule("test_rule").subject).to eq ["^(?!.*Re:).+$", ".*account_rule_subject.*"] } 13 | it { expect(Popper.configure.accounts.first.condition_by_rule("test_rule").utf_body).to eq [".*account_default_body.*"] } 14 | it { expect(Popper.configure.accounts.last.condition_by_rule("test_rule").subject).to eq ["^(?!.*Re:).+$", ".*account_rule_subject_2.*"] } 15 | it { expect(Popper.configure.accounts.last.condition_by_rule("test_rule").utf_body).to eq [".*account_default_body_2.*"] } 16 | end 17 | 18 | context 'action' do 19 | it { expect(Popper.configure.accounts.first.action_by_rule("test_rule").ghe.token).to eq "test_token" } 20 | it { expect(Popper.configure.accounts.first.action_by_rule("test_rule").ghe.url).to eq "https://ghe.example.com" } 21 | it { expect(Popper.configure.accounts.first.action_by_rule("test_rule").ghe.repo).to eq "example/rule" } 22 | it { expect(Popper.configure.accounts.last.action_by_rule("test_rule").ghe.token).to eq "test_token" } 23 | it { expect(Popper.configure.accounts.last.action_by_rule("test_rule").ghe.url).to eq "https://2.ghe.example.com" } 24 | it { expect(Popper.configure.accounts.last.action_by_rule("test_rule").ghe.repo).to eq "example/rule_2" } 25 | end 26 | end 27 | 28 | context 'single_file' do 29 | before do 30 | options = {} 31 | options[:config] = 'spec/fixture/popper_config_test.conf' 32 | Popper.load_config(options) 33 | end 34 | it_behaves_like 'check_config' 35 | end 36 | 37 | context 'include_file' do 38 | before do 39 | options = {} 40 | options[:config] = 'spec/fixture/popper_include.conf' 41 | Popper.load_config(options) 42 | end 43 | it_behaves_like 'check_config' 44 | end 45 | 46 | end 47 | -------------------------------------------------------------------------------- /spec/lib/lib/popper/action/exec_cmd_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'ostruct' 3 | describe Popper::Action::ExecCmd do 4 | describe '.task' do 5 | before do 6 | options = {} 7 | options[:config] = 'spec/fixture/popper_run.conf' 8 | options[:log] = '/tmp/popper.log' 9 | Popper.load_config(options) 10 | Popper.init_logger(options) 11 | 12 | Popper::Action::ExecCmd.instance_variable_set(:@action_config, OpenStruct.new({ cmd: true })) 13 | 14 | allow_any_instance_of(Tempfile).to receive(:path).and_return('dummy') 15 | expect_any_instance_of(Object).to receive(:system).with( 16 | 'true ' \ 17 | 'test\\ ok ' \ 18 | 'test\\ ok ' \ 19 | 'test@example.com ' \ 20 | 'test@example.com ' \ 21 | 'dummy ' \ 22 | 'dummy' 23 | ).and_return(true) 24 | end 25 | it { expect(Popper::Action::ExecCmd.task(attach_mail)).to be_truthy } 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/lib/lib/popper/action/webhook_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'ostruct' 3 | 4 | describe Popper::Action::Webhook do 5 | describe '.task' do 6 | before do 7 | options = {} 8 | options[:config] = 'spec/fixture/popper_run.conf' 9 | options[:log] = '/tmp/popper.log' 10 | Popper.load_config(options) 11 | Popper.init_logger(options) 12 | 13 | Popper::Action::Webhook.instance_variable_set(:@action_config, OpenStruct.new({ :webhook_url => 'http://localhost/webhook/event' })) 14 | 15 | allow_any_instance_of(Tempfile).to receive(:path).and_return("dummy") 16 | allow(Popper::Action::Webhook).to receive(:post!).and_return("ok") 17 | end 18 | 19 | it { expect(Popper::Action::Webhook.task(attach_mail)).to be_truthy } 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/lib/mail_account_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'slack-notifier' 3 | require 'octokit' 4 | describe Popper::MailAccount do 5 | describe 'run' do 6 | before do 7 | options = {} 8 | options[:config] = 'spec/fixture/popper_run.conf' 9 | options[:log] = '/tmp/popper.log' 10 | Popper.load_config(options) 11 | Popper.init_logger(options) 12 | 13 | allow_any_instance_of(Net::POP3).to receive(:start).and_yield(dummy_pop) 14 | 15 | ## exec command 16 | expect_any_instance_of(Object).to receive(:system).with( 17 | 'test_command ' \ 18 | 'default_condition\ account_rule_condition_subject ' \ 19 | "account_default_condition\\ account_rule_condition_body'\n' " \ 20 | 'no-reply@example.com ' \ 21 | 'example@example.com' 22 | ).and_return(true) 23 | 24 | # slack 25 | expect_any_instance_of(Slack::Notifier).to receive(:ping).with( 26 | "default_action_slack git:https://test.git.com/v3/issues/#123 ghe:https://test.ghe.com/v3/issues/#123", 27 | { 28 | attachments: [ 29 | { 30 | pretext: "2015-09-10T16:20:10+09:00", 31 | title: "default_condition account_rule_condition_subject", 32 | color: "good" 33 | } 34 | ] 35 | } 36 | ) 37 | 38 | # github 39 | allow_any_instance_of(Octokit::Client).to receive(:create_issue).with( 40 | "example/account_rule_action_git", 41 | "default_condition account_rule_condition_subject", 42 | "account_default_condition account_rule_condition_body\n", 43 | { labels: "first_gh_label,second_gh_label" } 44 | ).and_return( 45 | { html_url: "https://test.git.com/v3/issues/#123" } 46 | ) 47 | 48 | # ghe 49 | allow_any_instance_of(Octokit::Client).to receive(:create_issue).with( 50 | "example/account_rule_action_ghe", 51 | "default_condition account_rule_condition_subject", 52 | "account_default_condition account_rule_condition_body\n", 53 | { labels: "first_ghe_label,second_ghe_label" } 54 | ).and_return( 55 | { html_url: "https://test.ghe.com/v3/issues/#123" } 56 | ) 57 | 58 | # Webhook 59 | allow(Popper::Action::Webhook).to receive(:post!).with( 60 | "https://localhost:12345/webhook/event", 61 | "default_condition account_rule_condition_subject", 62 | "account_default_condition account_rule_condition_body\n" 63 | ).and_return("ok") 64 | 65 | @mail_account = described_class.new(Popper.configure.accounts.first) 66 | @mail_account.instance_variable_set(:@complete_list, [1]) 67 | end 68 | 69 | it { expect(@mail_account.run).to be_truthy } 70 | end 71 | 72 | describe 'match_rules?' do 73 | before do 74 | options = {} 75 | options[:config] = 'spec/fixture/popper_match_rules.conf' 76 | options[:log] = '/tmp/popper.log' 77 | Popper.load_config(options) 78 | Popper.init_logger(options) 79 | @mail_account = described_class.new(Popper.configure.accounts.first) 80 | end 81 | 82 | it { expect(@mail_account.match_rules?(ok_mail)).to be_truthy } 83 | it { expect(@mail_account.match_rules?(match_multiple_rules).size).to eq(2) } 84 | it { expect(@mail_account.match_rules?(ng_subject_mail)).to be_empty } 85 | it { expect(@mail_account.match_rules?(ng_body_mail)).to be_empty } 86 | end 87 | end 88 | 89 | class Dummy 90 | def flock(type) 91 | true 92 | end 93 | end 94 | 95 | def dummy_pop 96 | pop = Object.new 97 | def pop.mails 98 | pop_mail = Net::POPMail.new(nil, nil, nil, nil) 99 | pop_mail.uid = 100 100 | 101 | def pop_mail.delete 102 | true 103 | end 104 | 105 | def pop_mail.mail 106 | <<-EOS 107 | Delivered-To: example@example.com\r\nReceived: (qmail 5075 invoked from network); 10 Sep 2015 16:20:10 +0900\r\nTo: example@example.com\r\nSubject: default_condition account_rule_condition_subject\r\nFrom:no-reply@example.com\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=ISO-2022-JP\r\nContent-Transfer-Encoding: 7bit\r\nMessage-Id: <20150910072010.0545296845A@example.com>\r\nDate: Thu, 10 Sep 2015 16:20:10 +0900 (JST)\r\n\r\naccount_default_condition account_rule_condition_body 108 | EOS 109 | end 110 | [ 111 | pop_mail 112 | ] 113 | end 114 | pop 115 | end 116 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'popper' 2 | 3 | 4 | RSpec.configure do |config| 5 | config.expect_with :rspec do |expectations| 6 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 7 | end 8 | config.mock_with :rspec do |mocks| 9 | mocks.verify_partial_doubles = true 10 | end 11 | end 12 | 13 | 14 | def ok_mail 15 | mail = EncodeMail.new 16 | mail.subject = "test ok" 17 | mail.body = "test ok" 18 | mail 19 | end 20 | 21 | def ng_body_mail 22 | mail = EncodeMail.new 23 | mail.subject = "test ok" 24 | mail.body = "test ng" 25 | mail 26 | end 27 | 28 | def ng_subject_mail 29 | mail = EncodeMail.new 30 | mail.subject = "test ng" 31 | mail.body = "test ok" 32 | mail 33 | end 34 | 35 | def match_multiple_rules 36 | mail = EncodeMail.new 37 | mail.subject = "test ok" 38 | mail.body = "test match multiple rule" 39 | mail 40 | end 41 | 42 | def attach_mail 43 | mail = EncodeMail.new 44 | mail.from = "test@example.com" 45 | mail.to = "test@example.com" 46 | mail.subject = "test ok" 47 | mail.body = "test ok" 48 | mail.add_file('spec/fixture/attach1.jpg') 49 | mail.add_file('spec/fixture/attach2.jpg') 50 | mail 51 | end 52 | --------------------------------------------------------------------------------