├── .rbenv-version ├── .yardopts ├── lib ├── mail_to_hip_chat │ ├── version.rb │ ├── exceptions.rb │ ├── message_chutes │ │ ├── jenkins.rb │ │ ├── test_email.rb │ │ └── airbrake.rb │ ├── chute_chain.rb │ ├── message_chute.rb │ ├── rack_app │ │ └── builder.rb │ └── rack_app.rb └── mail_to_hip_chat.rb ├── .gitignore ├── Gemfile ├── Rakefile ├── test.rb ├── support └── config.ru ├── test ├── fixtures │ ├── airbrake_exception_body.txt │ ├── test_email_request_dump.txt │ └── airbrake_request_dump.txt ├── unit │ ├── message_chutes │ │ ├── test_email_test.rb │ │ └── airbrake_test.rb │ ├── message_chute_test.rb │ ├── chute_chain_test.rb │ └── rack_app_test.rb ├── integration │ └── airbrake_processing_test.rb └── test_helper.rb ├── mail_to_hip_chat.gemspec ├── LICENSE └── README.md /.rbenv-version: -------------------------------------------------------------------------------- 1 | 1.9.3 2 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --protected 2 | --no-private 3 | - 4 | ChangeLog 5 | LICENSE -------------------------------------------------------------------------------- /lib/mail_to_hip_chat/version.rb: -------------------------------------------------------------------------------- 1 | module MailToHipChat 2 | VERSION = "0.1.4" 3 | end 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | .bundle 3 | Gemfile.lock 4 | pkg/* 5 | .DS_Store 6 | /doc 7 | .yardoc 8 | -------------------------------------------------------------------------------- /lib/mail_to_hip_chat.rb: -------------------------------------------------------------------------------- 1 | require "mail_to_hip_chat/version" 2 | require "mail_to_hip_chat/exceptions" -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | # Specify your gem's dependencies in mail_to_hip_chat.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /lib/mail_to_hip_chat/exceptions.rb: -------------------------------------------------------------------------------- 1 | module MailToHipChat 2 | # Used to tag exceptions being raised from inside this library before they hit client code. 3 | module InternalError; end 4 | end -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | require "yard" 4 | require "mail_to_hip_chat" 5 | 6 | Rake::TestTask.new do |t| 7 | t.libs << "test" 8 | t.libs << "lib" 9 | t.test_files = FileList['test/{unit,integration}/**/*_test.rb'] 10 | t.verbose = true 11 | end 12 | 13 | YARD::Rake::YardocTask.new do |t| 14 | t.options += ['--title', "Mail To HipChat #{MailToHipChat::VERSION} Documentation"] 15 | end 16 | 17 | task :default => :test -------------------------------------------------------------------------------- /lib/mail_to_hip_chat/message_chutes/jenkins.rb: -------------------------------------------------------------------------------- 1 | require "mail_to_hip_chat/message_chute" 2 | require "mustache" 3 | 4 | module MailToHipChat 5 | module MessageChutes 6 | 7 | class JenkinsEmail 8 | include MessageChute 9 | 10 | def initialize(opts) 11 | initialize_hipchat_opts(opts) 12 | end 13 | 14 | def call(params) 15 | #return false unless params["subject"] =~ /testing setup/i 16 | message = Mustache.render("Message:
{{message}}", :message => params["plain"]) 17 | message_rooms("Jenkins", message) 18 | true 19 | end 20 | 21 | end 22 | end 23 | end -------------------------------------------------------------------------------- /test.rb: -------------------------------------------------------------------------------- 1 | AirbrakeDomain = "airbrake.io" 2 | msg = File.read("test/fixtures/airbrake_exception_body.txt") 3 | ExtractionExpression = %r[\A\n+Project:\s([^\n]+)\n+ # Pull out the project name 4 | Environment:\s([^\n]+)\n+ # Pull out the project environment 5 | # Pull out the URL to the exception 6 | ^(http://[^.]+\.#{Regexp.escape(AirbrakeDomain)}/errors/\d+)\n+ 7 | # Pull out the first line of the error message 8 | Error\sMessage:\n-{14}\n([^\n]+)]mx 9 | 10 | puts msg.match(ExtractionExpression).to_a.inspect 11 | -------------------------------------------------------------------------------- /support/config.ru: -------------------------------------------------------------------------------- 1 | require "mail_to_hip_chat" 2 | require "mail_to_hip_chat/rack_app" 3 | require "mail_to_hip_chat/message_chutes/airbrake" 4 | require "mail_to_hip_chat/message_chutes/test_email" 5 | 6 | app = MailToHipChat::RackApp::Builder.new do |builder| 7 | builder.secret = ENV['CLOUDMAILIN_SECRET'] 8 | builder.rooms = ENV['HIPCHAT_ROOMS'] 9 | builder.api_token = ENV['HIPCHAT_API_TOKEN'] 10 | 11 | # An included chute to process messages from Airbrake. 12 | builder.use_chute MailToHipChat::MessageChutes::Airbrake 13 | 14 | # This chute should be removed once you've confirmed your setup works. 15 | builder.use_chute MailToHipChat::MessageChutes::TestEmail 16 | end.to_app 17 | 18 | run app -------------------------------------------------------------------------------- /test/fixtures/airbrake_exception_body.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | Project: Echelon 4 | Environment: Staging 5 | 6 | 7 | http://the-nsa.airbrake.io/errors/11082122 8 | 9 | Error Message: 10 | -------------- 11 | AirbrakeTestingException: Testing airbrake via "rake airbrake:test". If you can see this, it works. 12 | 13 | Where: 14 | ------ 15 | application#verify 16 | [PROJECT_ROOT]/vendor/bundle/ruby/1.9.1/gems/activesupport-3.1.0/lib/active_support/callbacks.rb, line 412 17 | 18 | URL: 19 | ---- 20 | http://example.org/verify 21 | 22 | Backtrace Summary: 23 | ------------------ 24 | [PROJECT_ROOT]/lib/sha1_unhasher.rb:13:in `block in _call' 25 | [PROJECT_ROOT]/lib/sha1_unhasher.rb.rb:12:in `_call' 26 | [PROJECT_ROOT]/lib/sha1_unhasher.rb.rb:7:in `call' 27 | 28 | -------------------------------------------------------------------------------- /lib/mail_to_hip_chat/message_chutes/test_email.rb: -------------------------------------------------------------------------------- 1 | require "mail_to_hip_chat/message_chute" 2 | require "mustache" 3 | 4 | module MailToHipChat 5 | module MessageChutes 6 | # Takes a test email and sends it to the configured HipChat rooms, to verify a deployment based on this library 7 | # is working. 8 | class TestEmail 9 | include MessageChute 10 | 11 | def initialize(opts) 12 | initialize_hipchat_opts(opts) 13 | end 14 | 15 | def call(params) 16 | puts "PARAMS = " params 17 | return false unless params[:headers][:subject] =~ /testing setup/i 18 | message = Mustache.render("Message:
{{message}}", :message => params["plain"]) 19 | message_rooms("Testing", message) 20 | true 21 | end 22 | 23 | end 24 | 25 | end 26 | end -------------------------------------------------------------------------------- /test/unit/message_chutes/test_email_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "mail_to_hip_chat/message_chutes/test_email" 3 | 4 | class TestEmailMessageChuteTest < Test::Unit::TestCase 5 | 6 | def setup 7 | @hipchat_api = mock 8 | @airbrake_chute = MailToHipChat::MessageChutes::TestEmail.new(:rooms => "123", :hipchat_api => @hipchat_api) 9 | end 10 | 11 | test "a message with 'Testing Setup' in the subject in mixed case forwards to hipchat" do 12 | @hipchat_api.expects(:rooms_message).with(anything, "Testing", "Message:
test <1,2,3>") 13 | assert @airbrake_chute.call("subject" => "TeStInG SeTuP", "plain" => "test <1,2,3>") 14 | end 15 | 16 | test "a message with 'Testing Setup' anywhere in the subject forwards to hipchat" do 17 | @hipchat_api.expects(:rooms_message).with(anything, "Testing", "Message:
test <1,2,3>") 18 | assert @airbrake_chute.call("subject" => "i am testing setup", "plain" => "test <1,2,3>") 19 | end 20 | 21 | end -------------------------------------------------------------------------------- /lib/mail_to_hip_chat/chute_chain.rb: -------------------------------------------------------------------------------- 1 | module MailToHipChat 2 | # A {ChuteChain} is used to hold a collection of chutes and to check whether any of those chutes is 3 | # able to handle a message to hand off to HipChat. Anything that responds to #call can be pushed 4 | # onto the chain. 5 | class ChuteChain 6 | 7 | def initialize 8 | @chutes = [] 9 | end 10 | 11 | # Pushes a chute onto the chain. 12 | # 13 | # @param [#call] chute The chute to push onto the chain. 14 | # 15 | # @return [self] 16 | def push(chute) 17 | @chutes.push(chute) 18 | self 19 | end 20 | 21 | # Takes in a message and traverses the chain looking for a chute that will handle it. 22 | # 23 | # @param [Hash] message The message to give to the chutes in the chain. 24 | # 25 | # @return [true] True if a chute accepts the message. 26 | # @return [false] False if no chute can accept the message. 27 | def accept(message) 28 | @chutes.any? { |chute| chute.call(message) } 29 | end 30 | 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/mail_to_hip_chat/message_chute.rb: -------------------------------------------------------------------------------- 1 | require "hipchat-api" 2 | 3 | module MailToHipChat 4 | # Helpers for building a message chute. A message chute is something that responds to #call, and returns true if it's 5 | # able to deliver the message off to HipChat. A chute can be something as simple as a Proc object. The ones this 6 | # gem ship with are small classes that make use of this module. 7 | module MessageChute 8 | 9 | # @param [Hash] opts The options to create a message chute with 10 | # @option opts [Array, String] :rooms A room or an array of rooms to send messages to 11 | def initialize_hipchat_opts(opts) 12 | @rooms = Array(opts[:rooms]) 13 | @hipchat_api = opts[:hipchat_api] || HipChat::API.new(opts[:api_token]) 14 | end 15 | 16 | private 17 | 18 | # @param [#to_s] from The sender the message will show up as being from in HipChat 19 | # @param [#to_s] message The message to send 20 | def message_rooms(from, message) 21 | @rooms.each do |room_id| 22 | @hipchat_api.rooms_message(room_id, from, message) 23 | end 24 | end 25 | 26 | end 27 | end -------------------------------------------------------------------------------- /lib/mail_to_hip_chat/rack_app/builder.rb: -------------------------------------------------------------------------------- 1 | require "mail_to_hip_chat/rack_app" 2 | require "rack/builder" 3 | require "rack/urlmap" 4 | require "rack/commonlogger" 5 | 6 | module MailToHipChat 7 | class RackApp 8 | class Builder 9 | attr_accessor :secret, :rooms, :api_token, :mount_point 10 | 11 | def initialize 12 | @chutes = [] 13 | @mount_point = '/notifications/create' 14 | yield(self) if block_given? 15 | end 16 | 17 | def use_chute(chute_klass) 18 | @chutes << chute_klass 19 | end 20 | 21 | def to_app 22 | return @app if @app 23 | app = build_app 24 | mnt = mount_point 25 | @app = Rack::Builder.new do 26 | use Rack::CommonLogger 27 | map(mnt) { run app } 28 | end.to_app 29 | end 30 | 31 | private 32 | 33 | def build_app 34 | MailToHipChat::RackApp.new(:secret => @secret) do |f| 35 | @chutes.each do |chute_klass| 36 | f.use_chute(chute_klass.new(:api_token => @api_token, :rooms => split_rooms)) 37 | end 38 | end 39 | end 40 | 41 | def split_rooms 42 | @rooms.split(/,\s*/) 43 | end 44 | 45 | end 46 | end 47 | end -------------------------------------------------------------------------------- /mail_to_hip_chat.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | $:.push File.expand_path("../lib", __FILE__) 3 | require "mail_to_hip_chat/version" 4 | 5 | Gem::Specification.new do |s| 6 | s.name = "mail_to_hip_chat" 7 | s.version = MailToHipChat::VERSION 8 | s.authors = ["Gabriel Gironda"] 9 | s.email = ["gabriel@gironda.org"] 10 | s.homepage = "" 11 | s.summary = %q{Funnels email into HipChat} 12 | s.description = %q{Funnels email into HipChat using CloudMailIn} 13 | 14 | s.rubyforge_project = "mail_to_hip_chat" 15 | 16 | s.files = `git ls-files`.split("\n") 17 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 18 | s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 19 | s.require_paths = ["lib"] 20 | 21 | s.add_development_dependency "http_parser.rb", "~> 0.5" 22 | s.add_development_dependency "mocha", "~> 0.10" 23 | s.add_development_dependency "yard", "~> 0.7" 24 | s.add_development_dependency "rdiscount", "~> 1.6" 25 | s.add_development_dependency "webmock", "~> 1.7" 26 | s.add_development_dependency "rake" 27 | 28 | s.add_runtime_dependency "rack", "~> 1.3" 29 | s.add_runtime_dependency "hipchat-api", "~> 1.0" 30 | s.add_runtime_dependency "mustache", "~> 0.99" 31 | end 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2011, Gabriel Gironda 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a 4 | copy of this software and associated documentation files (the "Software"), 5 | to deal in the Software without restriction, including without limitation 6 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | and/or sell copies of the Software, and to permit persons to whom the 8 | Software is furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | DEALINGS IN THE SOFTWARE. 20 | 21 | Except as contained in this notice, the name of Gabriel Gironda shall not 22 | be used in advertising or otherwise to promote the sale, use or other 23 | dealings in this Software without prior written authorization from Gabriel 24 | Gironda. 25 | -------------------------------------------------------------------------------- /test/unit/message_chute_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "mail_to_hip_chat/message_chute" 3 | 4 | class MessageChuteTest < Test::Unit::TestCase 5 | class TestMessageChute 6 | include MailToHipChat::MessageChute 7 | 8 | def initialize(opts) 9 | initialize_hipchat_opts(opts) 10 | end 11 | 12 | def call(params) 13 | message_rooms(params[:sender], params[:message]) 14 | end 15 | end 16 | 17 | def setup 18 | @hipchat_api = mock 19 | end 20 | 21 | test "message_rooms uses the hipchat api to message the given room" do 22 | message_chute = TestMessageChute.new(:hipchat_api => @hipchat_api, :rooms => "123") 23 | @hipchat_api.expects(:rooms_message).with('123', "TestMessageChute", "example") 24 | message_chute.call(:sender => "TestMessageChute", :message => "example") 25 | end 26 | 27 | test "message_rooms uses the hipchat api to message all given rooms" do 28 | message_chute = TestMessageChute.new(:hipchat_api => @hipchat_api, :rooms => ["123", "456"]) 29 | 30 | room_messages = sequence('room messages') 31 | @hipchat_api.expects(:rooms_message).with('123', "TestMessageChute", "example").in_sequence(room_messages) 32 | @hipchat_api.expects(:rooms_message).with('456', "TestMessageChute", "example").in_sequence(room_messages) 33 | 34 | message_chute.call(:sender => "TestMessageChute", :message => "example") 35 | end 36 | 37 | end -------------------------------------------------------------------------------- /test/unit/message_chutes/airbrake_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "mail_to_hip_chat/message_chutes/airbrake" 3 | 4 | class AirbrakeMessageChuteTest < Test::Unit::TestCase 5 | 6 | def setup 7 | @hipchat_api = mock 8 | @airbrake_chute = MailToHipChat::MessageChutes::Airbrake.new(:rooms => "123", :hipchat_api => @hipchat_api) 9 | end 10 | 11 | test "a message with a parseable body is accepted" do 12 | @hipchat_api.expects(:rooms_message).with(any_parameters) 13 | message = read_fixture("airbrake_exception_body") 14 | assert @airbrake_chute.call("plain" => message) 15 | end 16 | 17 | test "a message with an unparseable body is rejected" do 18 | assert !@airbrake_chute.call("plain" => "zzzzzzz") 19 | end 20 | 21 | test "the message sent to HipChat contains exception information" do 22 | project, environment = "Echelon", "Staging" 23 | message = "AirbrakeTestingException: Testing airbrake via "rake airbrake:test". If you can see this, it works." 24 | url = "http://the-nsa.airbrake.io/errors/11082122" 25 | 26 | hipchat_message = %Q[#{project} - #{environment}
#{message}] 27 | 28 | @hipchat_api.expects(:rooms_message).with(anything, anything, hipchat_message) 29 | 30 | @airbrake_chute.call("plain" => read_fixture("airbrake_exception_body")) 31 | end 32 | 33 | test "the message is sent to HipChat as coming from an Airbrake user" do 34 | @hipchat_api.expects(:rooms_message).with(anything, "Airbrake", anything) 35 | 36 | @airbrake_chute.call("plain" => read_fixture("airbrake_exception_body")) 37 | end 38 | 39 | end -------------------------------------------------------------------------------- /lib/mail_to_hip_chat/rack_app.rb: -------------------------------------------------------------------------------- 1 | require "mail_to_hip_chat/rack_app/builder" 2 | require "mail_to_hip_chat/chute_chain" 3 | require "mail_to_hip_chat/exceptions" 4 | require "rack/request" 5 | require "digest/md5" 6 | 7 | module MailToHipChat 8 | class RackApp 9 | SIGNATURE_PARAM_NAME = "signature" 10 | 11 | def initialize(opts = {}) 12 | @secret = opts[:secret] 13 | @chute_chain = opts[:chute_chain] || ChuteChain.new 14 | 15 | yield(self) if block_given? 16 | end 17 | 18 | def call(rack_env) 19 | request = Rack::Request.new(rack_env) 20 | #return [400, {}, ['Bad Request.']] unless valid_request?(request) 21 | 22 | if @chute_chain.accept(request.params) 23 | [200, {}, ['OK']] 24 | else 25 | [404, {}, ['Not Found.']] 26 | end 27 | 28 | rescue Exception => error 29 | error.extend(InternalError) 30 | raise error 31 | end 32 | 33 | def use_chute(chute) 34 | @chute_chain.push(chute) 35 | puts "adding chain #{chute}" 36 | end 37 | 38 | private 39 | 40 | def valid_request?(request) 41 | computed_sig = create_sig_from_params(request.params) 42 | 43 | computed_sig == request.params[SIGNATURE_PARAM_NAME] 44 | end 45 | 46 | 47 | def create_sig_from_params(params) 48 | sorted_param_names = params.keys.sort 49 | sorted_param_names.delete(SIGNATURE_PARAM_NAME) 50 | 51 | param_vals_with_secret = params.values_at(*sorted_param_names) 52 | param_vals_with_secret.push(@secret) 53 | 54 | Digest::MD5.hexdigest(param_vals_with_secret.join) 55 | end 56 | 57 | end 58 | end -------------------------------------------------------------------------------- /test/unit/chute_chain_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "mail_to_hip_chat/chute_chain" 3 | 4 | class ChuteChainTest < Test::Unit::TestCase 5 | 6 | def setup 7 | @chute_chain = MailToHipChat::ChuteChain.new 8 | end 9 | 10 | test "pushing a chute returns the chute chain" do 11 | push_result = @chute_chain.push(lambda {}) 12 | assert_equal @chute_chain, push_result 13 | end 14 | 15 | test "chutes are traversed in the order they're pushed into the chain" do 16 | chute_traversal = sequence('chute traversal') 17 | 18 | chain_head_chute, chain_tail_chute = mock, mock 19 | chain_head_chute.expects(:call).in_sequence(chute_traversal) 20 | chain_tail_chute.expects(:call).in_sequence(chute_traversal) 21 | 22 | @chute_chain.push(chain_head_chute).push(chain_tail_chute) 23 | @chute_chain.accept({}) 24 | end 25 | 26 | test "traversing the chain stops at the first chute that returns a response" do 27 | chain_head_chute, chain_mid_chute, chain_tail_chute = mock, mock, mock 28 | 29 | chain_head_chute.expects(:call).returns(false) 30 | chain_mid_chute.expects(:call).returns(true) 31 | chain_tail_chute.expects(:call).never 32 | 33 | @chute_chain.push(chain_head_chute).push(chain_mid_chute).push(chain_tail_chute) 34 | @chute_chain.accept({}) 35 | end 36 | 37 | test "traversing the chain calls each chute with the given params" do 38 | params = {:test => :params} 39 | 40 | chain_head_chute, chain_tail_chute = mock, mock 41 | chain_head_chute.expects(:call).with(params) 42 | chain_tail_chute.expects(:call).with(params) 43 | 44 | @chute_chain.push(chain_head_chute).push(chain_tail_chute) 45 | @chute_chain.accept(params) 46 | end 47 | 48 | end -------------------------------------------------------------------------------- /test/integration/airbrake_processing_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "rack/builder" 3 | require "rack/utils" 4 | 5 | class AirbrakeProcessingTest < Test::Unit::TestCase 6 | include RackEnvHelper 7 | 8 | def setup 9 | WebMock.disable_net_connect! 10 | ENV['CLOUDMAILIN_SECRET'] = "6e50b93207454d57d587" 11 | ENV['HIPCHAT_ROOMS'] = "123" 12 | ENV['HIPCHAT_API_TOKEN'] = "example_auth_token" 13 | @app = app_from_config("support/config.ru") 14 | @airbrake_rack_env = rack_env_from_fixture("airbrake_request_dump") 15 | end 16 | 17 | def teardown 18 | WebMock.allow_net_connect! 19 | end 20 | 21 | test "an incoming request from airbrake via cloudmailin eventually posts to HipChat" do 22 | exception_url = "http://mailfunneltest.airbrake.io/errors/24746833" 23 | message = %Q[Mail Funnel - Unknown
RuntimeError: Test Exception] 24 | 25 | stub_request(:post, "https://api.hipchat.com/v1/rooms/message").to_return(:status => 200) 26 | 27 | @app.call(@airbrake_rack_env) 28 | 29 | request_params = {"auth_token" => "example_auth_token", "from" => "Airbrake", "room_id" => "123", "message" => message} 30 | 31 | assert_requested(:post, "https://api.hipchat.com/v1/rooms/message") do |req| 32 | # Unfortunately, WebMock doesn't actually give me an easy way to check whether only certain 33 | # values I care about are in the request body. Hence, this mess. 34 | body_params = Rack::Utils.parse_query(req.body) 35 | request_params.all? {|k,v| body_params[k] == v} 36 | end 37 | end 38 | 39 | private 40 | 41 | def app_from_config(config_file) 42 | app, options = Rack::Builder.parse_file(config_file, nil) 43 | app 44 | end 45 | 46 | end -------------------------------------------------------------------------------- /test/unit/rack_app_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "mail_to_hip_chat/rack_app" 3 | 4 | class RackAppConvenienceTest < Test::Unit::TestCase 5 | 6 | test "a tagged exception is raised if a chute raises an exception" do 7 | rack_app = MailToHipChat::RackApp.new 8 | rack_app.use_chute(lambda { |params| raise }) 9 | assert_raise(MailToHipChat::InternalError) do 10 | rack_app.call(Rack::Request.new({})) 11 | end 12 | end 13 | 14 | test "use_chute is sugar for pushing onto the chute chain" do 15 | chute_chain, chute = mock, lambda {} 16 | chute_chain.expects(:push).with(chute) 17 | rack_app = MailToHipChat::RackApp.new(:chute_chain => chute_chain) 18 | rack_app.use_chute(chute) 19 | end 20 | 21 | test "the constructor yields self if a block is given as sugar for configuring the chutes" do 22 | rack_app_from_block = nil 23 | rack_app = MailToHipChat::RackApp.new { |app| rack_app_from_block = app} 24 | assert_equal rack_app, rack_app_from_block 25 | end 26 | end 27 | 28 | class RackAppRequestTest < Test::Unit::TestCase 29 | include RackEnvHelper 30 | 31 | def setup 32 | @rack_env = rack_env_from_fixture('test_email_request_dump') 33 | end 34 | 35 | test "a request signed with a different secret returns a 400 error" do 36 | rack_app = MailToHipChat::RackApp.new(:secret => "weird secret") 37 | response = rack_app.call(@rack_env) 38 | assert_equal 400, response[0] 39 | end 40 | 41 | test "a request signed with the same secret returns a 404 if no chute accepts it" do 42 | rack_app = MailToHipChat::RackApp.new(:secret => "bcfe2d24c306ceabf4f1") 43 | response = rack_app.call(@rack_env) 44 | assert_equal 404, response[0] 45 | end 46 | 47 | test "a request signed with the same secret returns a 200 if a chute accepts it" do 48 | rack_app = MailToHipChat::RackApp.new(:secret => "bcfe2d24c306ceabf4f1") 49 | rack_app.use_chute(lambda { |params| true }) 50 | response = rack_app.call(@rack_env) 51 | assert_equal 200, response[0] 52 | end 53 | 54 | end -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require "test/unit" 2 | require "mocha" 3 | require "webmock/test_unit" 4 | require "pathname" 5 | require "http_parser.rb" 6 | require "stringio" 7 | 8 | class Test::Unit::TestCase 9 | FIXTURES_DIR = Pathname.new(__FILE__).parent + "fixtures" 10 | 11 | def self.test(test_description, &test_block) 12 | define_method("test #{test_description}", &test_block) 13 | end 14 | 15 | private 16 | 17 | def read_fixture(fixture_name) 18 | (FIXTURES_DIR + "#{fixture_name}.txt").read 19 | end 20 | 21 | module RackEnvHelper 22 | 23 | def rack_env_from_fixture(fixture_name) 24 | parser, request_body = Http::Parser.new, StringIO.new 25 | parser.on_body = proc {|chunk| request_body << chunk} 26 | parser.on_message_complete = proc { request_body.rewind } 27 | parser << read_fixture(fixture_name) 28 | 29 | rack_env = rack_normalize_headers(parser.headers) 30 | 31 | rack_env.merge!({"SCRIPT_NAME" => "", 32 | "REQUEST_METHOD" => parser.http_method, 33 | "PATH_INFO" => parser.request_path, 34 | "QUERY_STRING" => parser.query_string, 35 | "SERVER_NAME" => rack_env["HTTP_HOST"], 36 | "SERVER_PORT" => "443"}) 37 | 38 | rack_env.merge!({"rack.version" => [1, 3], 39 | "rack.url_scheme" => "https", 40 | "rack.input" => request_body, 41 | "rack.errors" => STDERR, 42 | "rack.multithread" => false, 43 | "rack.multiprocess" => false, 44 | "rack.run_once" => false}) 45 | 46 | rack_env 47 | end 48 | 49 | def rack_normalize_headers(headers_hash) 50 | headers_hash.inject({}) do |env,(header,value)| 51 | header = "http_#{header}" unless header =~ /\A(content-type|content-length)\Z/i 52 | env[header.upcase.tr('-', '_')] = value 53 | env 54 | end 55 | end 56 | 57 | end 58 | 59 | end -------------------------------------------------------------------------------- /lib/mail_to_hip_chat/message_chutes/airbrake.rb: -------------------------------------------------------------------------------- 1 | require "mail_to_hip_chat/message_chute" 2 | require "mustache" 3 | 4 | module MailToHipChat 5 | module MessageChutes 6 | # Takes exception notification emails from Airbrake and sends them to the configured HipChat rooms. 7 | class Airbrake 8 | include MessageChute 9 | 10 | def initialize(opts) 11 | initialize_hipchat_opts(opts) 12 | end 13 | 14 | def call(params) 15 | return false unless process_message(params["plain"]) 16 | true 17 | end 18 | 19 | private 20 | 21 | def airbrake_domain 22 | "airbrake.io" 23 | end 24 | 25 | def extraction_expression 26 | %r[\A\n+Project:\s([^\n]+)\n+ # Pull out the project name 27 | Environment:\s([^\n]+)\n+ # Pull out the project environment 28 | # Pull out the URL to the exception 29 | ^(http://[^.]+\.#{Regexp.escape(airbrake_domain)}/errors/\d+)\n+ 30 | # Pull out the first line of the error message 31 | Error\sMessage:\n-{14}\n([^\n]+)]mx 32 | end 33 | 34 | def hipchat_sender 35 | "Airbrake" 36 | end 37 | 38 | def message_template 39 | %q[{{project}} - {{environment}}
{{message}}] 40 | end 41 | 42 | def process_message(plaintext) 43 | return nil unless parts = extract_parts(plaintext) 44 | send_notifications(parts) 45 | true 46 | end 47 | 48 | def extract_parts(plaintext) 49 | return nil unless parts = plaintext.match(extraction_expression) 50 | [:project, :environment, :url, :message].each_with_index.inject({}) do |hash,(key,idx)| 51 | hash[key] = parts[idx + 1] 52 | hash 53 | end 54 | end 55 | 56 | def send_notifications(message_parts) 57 | hipchat_message = Mustache.render(message_template, message_parts) 58 | message_rooms(hipchat_sender, hipchat_message) 59 | end 60 | 61 | end 62 | end 63 | end -------------------------------------------------------------------------------- /test/fixtures/test_email_request_dump.txt: -------------------------------------------------------------------------------- 1 | POST / HTTP/1.1 2 | user-agent: CloudMailin Server 3 | content-type: multipart/form-data; boundary=----cloudmailinboundry 4 | content-length: 2827 5 | connection: close 6 | host: vgwb.showoff.io 7 | x-forwarded-for: undefined 8 | 9 | ------cloudmailinboundry 10 | Content-Disposition: form-data; name="to" 11 | 12 | <16fefd79fee99cc47215@cloudmailin.net> 13 | ------cloudmailinboundry 14 | Content-Disposition: form-data; name="disposable" 15 | 16 | 17 | ------cloudmailinboundry 18 | Content-Disposition: form-data; name="from" 19 | 20 | gabrielg.test@example.com 21 | ------cloudmailinboundry 22 | Content-Disposition: form-data; name="subject" 23 | 24 | This is a test email to CloudMailIn 25 | ------cloudmailinboundry 26 | Content-Disposition: form-data; name="message" 27 | 28 | Received: by ggnr4 with SMTP id r4so955040ggn.3 29 | for <16fefd79fee99cc47215@cloudmailin.net>; Wed, 09 Nov 2011 21:15:19 -0800 (PST) 30 | DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; 31 | d=gmail.com; s=gamma; 32 | h=from:content-type:content-transfer-encoding:subject:date:message-id 33 | :to:mime-version:x-mailer; 34 | bh=A9jPqHVi05+rBptC4qDZ74YQYcYcOvVD/GoK4riS6zk=; 35 | b=sNFTGMEfly/MtKvlbsc0RqIzfVmi7eGjwFfFxJV2NhTt05dQbUNEtiNqfi7ohezG1A 36 | DCMVszc7qw0NtJv37Qgn5C8bjJIdR+fcR66lRVCDO/6uf4JymKupXPyw9B2bWjW7chgn 37 | yHgcQtMtx4XUloNgxI9Q0U8Ent+JH1NkM34js= 38 | Received: by 10.100.123.10 with SMTP id v10mr2641390anc.168.1320902119413; 39 | Wed, 09 Nov 2011 21:15:19 -0800 (PST) 40 | Return-Path: 41 | Received: from [10.0.1.54] (200-111-223-122.c3-0.mct-ubr1.chi-mct.il.cable.rcn.com. [200.111.223.122]) 42 | by mx.google.com with ESMTPS id l27sm20556805ani.21.2011.11.09.21.15.17 43 | (version=SSLv3 cipher=OTHER); 44 | Wed, 09 Nov 2011 21:15:18 -0800 (PST) 45 | From: Gabriel Gironda 46 | Content-Type: text/plain; charset=us-ascii 47 | Content-Transfer-Encoding: 7bit 48 | Subject: This is a test email to CloudMailIn 49 | Date: Wed, 9 Nov 2011 23:15:16 -0600 50 | Message-Id: <61277AE7-938D-43CA-80CC-86AB47890FCE@gmail.com> 51 | To: 16fefd79fee99cc47215@cloudmailin.net 52 | Mime-Version: 1.0 (Apple Message framework v1251.1) 53 | X-Mailer: Apple Mail (2.1251.1) 54 | 55 | This is the body of the test email. 56 | 57 | ------cloudmailinboundry 58 | Content-Disposition: form-data; name="plain" 59 | 60 | This is the body of the test email. 61 | ------cloudmailinboundry 62 | Content-Disposition: form-data; name="html" 63 | 64 | 65 | ------cloudmailinboundry 66 | Content-Disposition: form-data; name="mid" 67 | 68 | 61277AE7-938D-43CA-80CC-86AB47890FCE@gmail.com 69 | ------cloudmailinboundry 70 | Content-Disposition: form-data; name="x_to_header" 71 | 72 | ["16fefd79fee99cc47215@cloudmailin.net"] 73 | ------cloudmailinboundry 74 | Content-Disposition: form-data; name="x_cc_header" 75 | 76 | 77 | ------cloudmailinboundry 78 | Content-Disposition: form-data; name="helo_domain" 79 | 80 | mail-gx0-f172.google.com 81 | ------cloudmailinboundry 82 | Content-Disposition: form-data; name="return_path" 83 | 84 | gabrielg.test@example.com 85 | ------cloudmailinboundry 86 | Content-Disposition: form-data; name="signature" 87 | 88 | a6fc5b4ab888ead36ea00aa430ad310a 89 | ------cloudmailinboundry-- -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | _ 2 | [_| . . . . . 3 | .-----|--, ,-,-. ,-. . | |- ,-. |-. . ,-. ,-. |-. ,-. |- 4 | / | /_\_ | | | ,-| | | | | | | | | | | | | | ,-| | 5 | | |__.-| ' ' ' `-^ ' `'---`' `-'---' ' ' |-'---`-' ' ' `-^ `' 6 | | |__\_| | 7 | '---,-,-;---' ' 8 | | | 9 | 10 | # Mail To HipChat 11 | 12 | Mail To HipChat lets you wire up email notifications to [HipChat](http://hipchat.com/r/30ad1). This is useful in situations where a third-party service doesn't have a real [WebHook](http://www.webhooks.org/) available, but you still want to be able to dump notifications into HipChat. The [Airbrake](http://airbrake.io) exception notification service is one example. 13 | 14 | ## How It Works 15 | 16 | Mail To HipChat is designed to be deployed on [Heroku](http://heroku.com) and used with the [CloudMailIn](http://cloudmailin.com/) add-on. Adding the CloudMailIn incoming email address to the recipients list of the service you wish to integrate with will shuffle emails off to your instance of this tool as a HTTP POST request. A list of mail handlers is checked, and the appropriate one is invoked to format a message and send it off to one or more HipChat rooms. 17 | 18 | ## Prerequisites 19 | 20 | You will need an account on Heroku and admin access to your HipChat group. 21 | 22 | ## Initial Setup 23 | 24 | 1. Make a home for your shiny new Mail To HipChat instance to live in. 25 | 26 | $ mkdir my-mail_to_hip_chat 27 | $ cd my-mail_to_hip_chat 28 | $ git init 29 | 30 | 2. Do the bundler dance. 31 | 32 | $ echo 'source "http://rubygems.org"' >> Gemfile 33 | $ echo 'gem "mail_to_hip_chat"' >> Gemfile 34 | $ bundle install 35 | $ git add Gemfile Gemfile.lock 36 | $ git commit -m "Getting down with bundler" 37 | 38 | 3. Copy over the default config.ru. 39 | 40 | $ cp "`bundle show mail_to_hip_chat`/support/config.ru" . 41 | $ git add config.ru 42 | $ git commit -m "Adding default config.ru" 43 | 44 | 4. Set up a new Heroku application with CloudMailIn and Piggyback SSL. 45 | 46 | $ heroku create --stack cedar 47 | $ heroku addons:add cloudmailin 48 | $ heroku addons:add ssl:piggyback 49 | 50 | 5. Setup the CloudMailIn target address to point at your app. 51 | 52 | Get the target address for CloudMailIn to hit when it receives an email. 53 | 54 | $ echo "`heroku info -r | grep web_url | cut -d '=' -f 2 | sed 's/^http/https/'`notifications/create" 55 | 56 | Get your CloudMailIn username and password from the Heroku app config. 57 | 58 | $ heroku config | grep CLOUDMAILIN 59 | 60 | [Log in to CloudMailIn](https://cloudmailin.com/users/sign_in) using the given `CLOUDMAILIN_USERNAME` and `CLOUDMAILIN_PASSWORD`. You'll see the entry for `CLOUDMAILIN_FORWARD_ADDRESS` in the list. Hit "Manage" and then "Edit Target", and set the target to the target address we retrieved above. 61 | 62 | 6. Get a HipChat API token and the ID for the Room(s) to send messages to. 63 | 64 | Visit your [HipChat API Admin page](http://hipchat.com/group_admin/api). If there's a token you want to already use, use that one. If not, create a new token of type "Notification". Once you have the token, set it as an environment variable on Heroku. 65 | 66 | $ heroku config:add HIPCHAT_API_TOKEN=fd3deeef7b88b95c1780e6237c41c30f 67 | 68 | Visit your [HipChat Chat History page](https://hipchat.com/history). The integer in the URL for the history of each room (for example, `https://gabe.hipchat.com/history/room/31373`) is the room ID. Once you have the ID(s) of the rooms you wish to send messages to, set it as a comma separated environment variable on Heroku. 69 | 70 | $ heroku config:add HIPCHAT_ROOMS=31373,31374 71 | 72 | 7. Deploy this sucker. 73 | 74 | $ git push heroku master 75 | 76 | 8. Get your CloudMailIn forwarding address and send a test email. 77 | 78 | $ heroku config --long | grep CLOUDMAILIN_FORWARD_ADDRESS 79 | 80 | Send an email to the `CLOUDMAILIN_FORWARD_ADDRESS`, with a subject of "Testing Setup". The message should appear in the rooms you've configured Mail To HipChat to send messages to. 81 | 82 | ## Hooking it up to Airbrake 83 | 84 | Just add a new user to your project, set their email address to `CLOUDMAILIN_FORWARD_ADDRESS`, and watch the exceptions roll in. Then turn your face from God and weep in abject horror. 85 | 86 | ## Adding new message handlers 87 | 88 | See the included Airbrake handler for an example. Really, any object that has a `call` method that takes one argument can be used. 89 | 90 | ## FAQ 91 | 92 | * Can I use something besides CloudMailIn/Heroku? 93 | 94 | Most likely, yes. The code has only been tested using CloudMailIn and Heroku, but really, there's not that much tying it to that particular kind of setup, besides the CloudMailIn signature verification stuff in the Rack app. 95 | 96 | * Could I say, feed it email using a `.forward` file? 97 | 98 | Probably. The only part dependent on actually being a web app is the Rack application itself. 99 | 100 | * How do your projects end up with such rad ASCII art? 101 | 102 | $ figlist | xargs -I {} /bin/bash -c "figlet -f {} your_project_name" 103 | $ open "http://google.com/search?q=something_related_to_your_project+ascii+art" 104 | 105 | ## TODO 106 | 107 | * There are too many steps for initial setup. This needs to be cut down. -------------------------------------------------------------------------------- /test/fixtures/airbrake_request_dump.txt: -------------------------------------------------------------------------------- 1 | POST /notifications/create HTTP/1.1 2 | user-agent: CloudMailin Server 3 | content-type: multipart/form-data; boundary=----cloudmailinboundry 4 | connection: close 5 | host: nvg7.showoff.io 6 | content-length: 13721 7 | x-forwarded-for: undefined 8 | 9 | ------cloudmailinboundry 10 | Content-Disposition: form-data; name="to" 11 | 12 | <60453ebc38386e438aae@cloudmailin.net> 13 | ------cloudmailinboundry 14 | Content-Disposition: form-data; name="disposable" 15 | 16 | 17 | ------cloudmailinboundry 18 | Content-Disposition: form-data; name="from" 19 | 20 | bounces+5166-c14f-60453ebc38386e438aae=cloudmailin.net@mail.airbrakeapp.com 21 | ------cloudmailinboundry 22 | Content-Disposition: form-data; name="subject" 23 | 24 | [Mail Funnel] Unknown: RuntimeError: Test Exception 25 | ------cloudmailinboundry 26 | Content-Disposition: form-data; name="message" 27 | 28 | DKIM-Signature: v=1; a=rsa-sha1; c=relaxed; d=airbrake.io; h=date:from 29 | :to:message-id:subject:mime-version:content-type 30 | :content-transfer-encoding; s=smtpapi; bh=KKlxopAqERodeAyNLF18/K 31 | MxQj0=; b=b47TkkTEkDIOxbpJ+XcYGDuM2vgffC40AL4MyhSTZ2y9mPb66cWCIR 32 | WoV9x3g7hNK0Wn5LCRVNV9GmHmNUB2OSgVddE4/8H9YIe/iikNU0xWyRgqIoB07z 33 | Cya5nE3FnDkrrvAj1HOK4j7Zh8GKzWWzc7DAcMjdxZoKfi64Th7Lw= 34 | DomainKey-Signature: a=rsa-sha1; c=nofws; d=airbrake.io; h=date:from:to 35 | :message-id:subject:mime-version:content-type 36 | :content-transfer-encoding; q=dns; s=smtpapi; b=AXe+uQRX1MlGoqN6 37 | 4mQYhNdSpzF06o/LOZfPES1q/IrUd/Ri4xxB7T8vRJj2x+iRPHaqBjc54sLA05Y1 38 | edPVLUba3fHn4/lVWJA8L5/xZNdFtSNQO2TaHY4EFVXo+nnAY9fDeRQnzNdmnfxd 39 | uUTg6/dIgKxzscMjqd+7+0lbkxM= 40 | Received: by 10.16.69.80 with SMTP id mf39.6551.4EC9B2C84 41 | Sun, 20 Nov 2011 20:09:12 -0600 (CST) 42 | Received: from mail-gw.airbrakeapp.com (unknown [10.9.180.5]) 43 | by mi12 (SG) with ESMTP id 4ec9b2c8.318c.10f7ae 44 | for <60453ebc38386e438aae@cloudmailin.net>; Sun, 20 Nov 2011 20:09:12 -0600 (CST) 45 | Received: from mail-gw.airbrakeapp.com (localhost.localdomain [127.0.0.1]) 46 | by mail-gw.airbrakeapp.com (Postfix) with ESMTP id 39B5460C005 47 | for <60453ebc38386e438aae@cloudmailin.net>; Sun, 20 Nov 2011 18:09:12 -0800 (PST) 48 | DKIM-Signature: v=1; a=rsa-sha1; c=relaxed; d=airbrakeapp.com; h=date 49 | :from:to:message-id:subject:mime-version:content-type 50 | :content-transfer-encoding; s=mailgw; bh=dSQtQ4kIv7VPSmdHkHwB2/5 51 | 9ITw=; b=pF/mMER+KTRXiuEC5VTNp82AnpOGheZWHlRSUE2cah8KUMgPBAHslOE 52 | 9ah3Y/4KhZsQb3qN/X3frclbJTvOpV5ZW5Iwdt/7Z8hxWXu7LrREuFmn9GkSICdF 53 | odXLlhv+djVBAQ3NiboOBDE2AWt2gdumOYamPpvscBcS9I3gnCD0= 54 | Received: from airbrake.io (app04.c45163.blueboxgrid.com [67.214.215.208]) 55 | by mail-gw.airbrakeapp.com (Postfix) with ESMTP id 3661A60C004 56 | for <60453ebc38386e438aae@cloudmailin.net>; Sun, 20 Nov 2011 18:09:12 -0800 (PST) 57 | Date: Sun, 20 Nov 2011 18:09:12 -0800 58 | From: Airbrake 59 | To: 60453ebc38386e438aae@cloudmailin.net 60 | Message-ID: <4ec9b2c832055_5d79107e0a6c102209@app04.c45163.blueboxgrid.com.mail> 61 | Subject: [Mail Funnel] Unknown: RuntimeError: Test Exception 62 | Mime-Version: 1.0 63 | Content-Type: multipart/alternative; 64 | boundary="--==_mimepart_4ec9b2c82d851_5d79107e0a6c10217ae"; 65 | charset=UTF-8 66 | Content-Transfer-Encoding: 7bit 67 | X-Sendgrid-EID: nmcxq1y3RJKAPQ7Sa91nulloMuqlCCsUjgwVCyO7bUPisBsGZlon70J9+djovKtmzRRVbZRyHuCVVM5ojJD9O0lyIkJT31YYJGiqJ6azBv0rJirHI2zEqJQzKjVTxeWI4PGnpucBIOio+5Uj0gwTgosIpF2aJjCuIJ3o4funNQ8= 68 | 69 | 70 | ----==_mimepart_4ec9b2c82d851_5d79107e0a6c10217ae 71 | Date: Sun, 20 Nov 2011 18:09:12 -0800 72 | Mime-Version: 1.0 73 | Content-Type: text/plain; 74 | charset=UTF-8 75 | Content-Transfer-Encoding: 7bit 76 | Content-ID: <4ec9b2c8307a9_5d79107e0a6c10218e7@app04.c45163.blueboxgrid.com.mail> 77 | 78 | 79 | 80 | Project: Mail Funnel 81 | Environment: Unknown 82 | 83 | 84 | http://mailfunneltest.airbrake.io/errors/24746833 85 | 86 | Error Message: 87 | -------------- 88 | RuntimeError: Test Exception 89 | 90 | Where: 91 | ------ 92 | /private/tmp/app.rb, line 10 93 | 94 | URL: 95 | ---- 96 | http://localhost:7000/ 97 | 98 | Backtrace Summary: 99 | ------------------ 100 | 101 | 102 | 103 | 104 | ----==_mimepart_4ec9b2c82d851_5d79107e0a6c10217ae 105 | Date: Sun, 20 Nov 2011 18:09:12 -0800 106 | Mime-Version: 1.0 107 | Content-Type: text/html; 108 | charset=UTF-8 109 | Content-Transfer-Encoding: 7bit 110 | Content-ID: <4ec9b2c831524_5d79107e0a6c10219fd@app04.c45163.blueboxgrid.com.mail> 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 144 | 145 | 146 | 147 | 174 | 175 | 176 |
121 | 122 | 123 | 126 | 131 | 132 | 133 | 136 | 141 | 142 |
Project: 128 | 129 | Mail Funnel 130 |
Environment: 138 | 139 | Unknown 140 |
143 |
148 | 149 | 150 | 171 | 172 |
151 |
152 | 153 |

An error has just occurred. View full details at:

154 |

155 | 156 | http://mailfunneltest.airbrake.io/errors/24746833 157 |

158 | 159 |

Error Message:

160 |

RuntimeError: Test Exception

161 | 162 |

Where:

163 |

/private/tmp/app.rb, line 10

164 |

URL:

165 |

http://localhost:7000/

166 |

Backtrace Summary:

167 | 168 |
169 | 170 |
173 |
177 | 178 | 185 | 186 |
179 | Delivered by Airbrake.
180 | 181 |
182 | These ads are powered by San Francisco based startup Launchbit. You are seeing these ads because you are on our free plan tier. Upgrade your plan now to start tracking your deploys, avail of SSL connections and receive ad-free emails 183 |
184 |
187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | ----==_mimepart_4ec9b2c82d851_5d79107e0a6c10217ae-- 198 | 199 | ------cloudmailinboundry 200 | Content-Disposition: form-data; name="plain" 201 | 202 | 203 | 204 | Project: Mail Funnel 205 | Environment: Unknown 206 | 207 | 208 | http://mailfunneltest.airbrake.io/errors/24746833 209 | 210 | Error Message: 211 | -------------- 212 | RuntimeError: Test Exception 213 | 214 | Where: 215 | ------ 216 | /private/tmp/app.rb, line 10 217 | 218 | URL: 219 | ---- 220 | http://localhost:7000/ 221 | 222 | Backtrace Summary: 223 | ------------------ 224 | ------cloudmailinboundry 225 | Content-Disposition: form-data; name="html" 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 259 | 260 | 261 | 262 | 289 | 290 | 291 |
236 | 237 | 238 | 241 | 246 | 247 | 248 | 251 | 256 | 257 |
Project: 243 | 244 | Mail Funnel 245 |
Environment: 253 | 254 | Unknown 255 |
258 |
263 | 264 | 265 | 286 | 287 |
266 |
267 | 268 |

An error has just occurred. View full details at:

269 |

270 | 271 | http://mailfunneltest.airbrake.io/errors/24746833 272 |

273 | 274 |

Error Message:

275 |

RuntimeError: Test Exception

276 | 277 |

Where:

278 |

/private/tmp/app.rb, line 10

279 |

URL:

280 |

http://localhost:7000/

281 |

Backtrace Summary:

282 | 283 |
284 | 285 |
288 |
292 | 293 | 300 | 301 |
294 | Delivered by Airbrake.
295 | 296 |
297 | These ads are powered by San Francisco based startup Launchbit. You are seeing these ads because you are on our free plan tier. Upgrade your plan now to start tracking your deploys, avail of SSL connections and receive ad-free emails 298 |
299 |
302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | ------cloudmailinboundry 310 | Content-Disposition: form-data; name="mid" 311 | 312 | 4ec9b2c832055_5d79107e0a6c102209@app04.c45163.blueboxgrid.com.mail 313 | ------cloudmailinboundry 314 | Content-Disposition: form-data; name="x_to_header" 315 | 316 | ["60453ebc38386e438aae@cloudmailin.net"] 317 | ------cloudmailinboundry 318 | Content-Disposition: form-data; name="x_cc_header" 319 | 320 | 321 | ------cloudmailinboundry 322 | Content-Disposition: form-data; name="signature" 323 | 324 | a7305c6fe551d80a7c56ab1746922619 325 | ------cloudmailinboundry-- 326 | --------------------------------------------------------------------------------