diff --git a/.gitignore b/.gitignore index 93bf608..cb58e60 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ .idea Rack* +start_mailphax_server.sh + diff --git a/Gemfile b/Gemfile index 0287d04..2f88cb3 100644 --- a/Gemfile +++ b/Gemfile @@ -1,7 +1,8 @@ source 'https://rubygems.org' -ruby '2.0.0' +ruby '2.3.0' gem 'sinatra' gem 'phaxio' gem 'mail' -gem 'pony' \ No newline at end of file +gem 'pony' +gem 'to_regexp' \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock index a0c56ef..19e9c70 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -7,7 +7,7 @@ GEM httparty (0.13.0) json (~> 1.8) multi_xml (>= 0.5.2) - json (1.8.1) + json (1.8.2) mail (2.6.1) mime-types (>= 1.16, < 3) mime-types (2.3) @@ -25,6 +25,7 @@ GEM rack-protection (~> 1.4) tilt (~> 1.3, >= 1.3.4) tilt (1.4.1) + to_regexp (0.2.1) PLATFORMS ruby @@ -34,3 +35,7 @@ DEPENDENCIES phaxio pony sinatra + to_regexp + +BUNDLED WITH + 1.11.2 diff --git a/README.md b/README.md index 5ef51b2..fccad3b 100644 --- a/README.md +++ b/README.md @@ -3,26 +3,6 @@ Mailphax Send faxes with Phaxio using 3rd party email services. Mailphax is a simple sinatra app. You can run it on any host or with any service that supports ruby and sinatra. - -Installation on Heroku ------------- - -**Use the deploy button** - -[![Deploy](https://www.herokucdn.com/deploy/button.png)](https://heroku.com/deploy) - -**Or do it yourself** - -(This assumes you have the Heroku toolbelt installed and have a Heroku account.) - -1. git clone this repo && cd mailphax -1. heroku create -1. heroku config:set PHAXIO_KEY=yourPhaxioApiKey -1. heroku config:set PHAXIO_SECRET=yourPhaxioApiSecret -1. git push heroku master - -Now set up your hosted email service to invoke callbacks to this service when mail is received. (See below.) - Configuring Mailgun ------- 1. Sign up for a mailgun account @@ -36,30 +16,3 @@ Configuring Mailgun 1. For "Actions" specify "forward("http://yourMailPhaxInstallation/mailgun")" where yourMailPhaxInstallation should be the location where you've installed the sinatra app. 1. Click "Save". 1. Profit. - - -Configuring Mandrill --------------------- - -1. Sign up for a mandrill account -1. In the Mandrill console, click "Inbound" in the left sidebar. -1. Add a new inbound domain that you have DNS control over. -1. Modify the DNS on your inbound domain to point to Mandrill using MX records. (Click the "DNS Settings" button for more info.) -1. Click "Routes" in the Mandrill console under your new inbound domain. -1. Add a wildcard route "*" and point it to http://yourMailPhaxInstallation/mandrill (e.g. http://example.com/mandrill) -1. Profit. - - -Configuring SendGrid -------- -TODO - - -TODOs ------ - - - Support SendGrid - - Reply to user with confirmation of fax success/failure - - Reply to user if fax submission failed (e.g. bad number, no attachment) - - Allow filtering inbound emails by regexes - diff --git a/app.json b/app.json index f2be2cd..209ac5a 100644 --- a/app.json +++ b/app.json @@ -17,6 +17,21 @@ "PHAXIO_SECRET": { "description": "Your Phaxio API Secret" }, + "MAILGUN_KEY": { + "description": "Your Mailgun API Key" + }, + "RECIPIENT_WHITELIST_FILE": { + "description": "File path to newline-separated list of whitelisted emails of recipients", + "required": false + }, + "SENDER_WHITELIST_FILE": { + "description": "File path to newline-separated list of whitelisted emails of senders", + "required": false + }, + "BODY_REGEX": { + "description": "Ruby Regexp string (of form '/{regex string}/{regex arguments}') to match against email body", + "required": false + }, "SMTP_HOST": { "description": "SMTP host for outgoing email (for failure alerts)", "required": false diff --git a/mailphax.rb b/mailphax.rb index d035164..42cd09e 100644 --- a/mailphax.rb +++ b/mailphax.rb @@ -2,62 +2,170 @@ require 'phaxio' require 'mail' require 'pony' +require 'tempfile' +require 'openssl' +require 'to_regexp' -if not ENV['PHAXIO_KEY'] or not ENV['PHAXIO_SECRET'] - raise "You must specify your phaxio API keys in PHAXIO_KEY and PHAXIO_SECRET" +if not ENV['PHAXIO_KEY'] or not ENV['PHAXIO_SECRET'] or not ENV['MAILGUN_KEY'] + raise "You must specify the required environment variables" end get '/' do - "Mailfax v1.0 - Visit a mail endpoint: (/sendgrid, /mandrill, /mailgun)" + "MailPhax v1.0 - Visit a mail endpoint: (/mailgun)" end +get '/mailgun' do + [400, "Mailgun supported, but callbacks must be POSTs"] +end -get '/mandrill' do - [501, "mandrill not implemented yet"] +$recipientWhitelist = nil + +def getRecipientWhitelist() + if $recipientWhitelist.nil? + if ENV['RECIPIENT_WHITELIST_FILE'] + $recipientWhitelist = File.read(ENV['RECIPIENT_WHITELIST_FILE']).split + end + end + return $recipientWhitelist end -post '/mandrill' do - [501, "mandrill not implemented yet"] +$senderWhitelist = nil + +def getSenderWhitelist() + if $senderWhitelist.nil? + if ENV['SENDER_WHITELIST_FILE'] + $senderWhitelist = File.read(ENV['SENDER_WHITELIST_FILE']).split + end + end + return $senderWhitelist end -get '/mailgun' do - [400, "Mailgun supported, but callbacks must be POSTs"] +$bodyRegex = nil + +def getBodyRegex() + if $bodyRegex.nil? + if ENV['BODY_REGEX'] + $bodyRegex = ENV['BODY_REGEX'].to_regexp + end + end + return $bodyRegex +end + +def verifyMailgun(apiKey, token, timestamp, signature) + calculatedSignature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA256.new(), apiKey, [timestamp, token].join()) + signature == calculatedSignature end +mailgunTokenCache = [] + post '/mailgun' do + mailgunTokenCacheMaxLength = 50 + timestampThreshold = 30.0 - if not params['sender'] - return [400, "Must include a sender"] - elsif not params['recipient'] - return [400, "Must include a recipient"] + sender = params['sender'] + if not sender + return logAndResponse(400, "Must include a sender", logger) end - files = [] - attachmentCount = params['attachment-count'].to_i + senderWhitelist = getSenderWhitelist() + if not senderWhitelist.nil? and not senderWhitelist.include? sender + return logAndResponse(401, "sender blocked", logger) + end + + recipient = params['recipient'] + if not recipient + return logAndResponse(400, "Must include a recipient", logger) + end + + recipientWhitelist = getRecipientWhitelist() + if not recipientWhitelist.nil? and not recipientWhitelist.include? recipient + return logAndResponse(401, "recipient blocked", logger) + end + token = params['token'] + if not token + return logAndResponse(400, "Must include a token", logger) + end + + signature = params['signature'] + if not signature + return logAndResponse(400, "Must include a signature", logger) + end + + timestamp = params['timestamp'] + if not timestamp + return logAndResponse(400, "Must include a timestamp", logger) + end + + if mailgunTokenCache.include?(token) + return logAndResponse(400, "duplicate token", logger) + end + + mailgunTokenCache.push(token) + while mailgunTokenCache.length() > mailgunTokenCacheMaxLength + mailgunTokenCache.pop() + end + + timestampSeconds = timestamp.to_f + nowSeconds = Time.now().to_f + if (timestampSeconds - nowSeconds).abs() > timestampThreshold + return logAndResponse(400, "timestamp unsafe", logger) + end + + if not verifyMailgun(ENV['MAILGUN_KEY'], token, timestamp, signature) + return logAndResponse(400, "signature does not verify", logger) + end + + attachmentFiles = [] + + attachmentCount = params['attachment-count'].to_i i = 1 while i <= attachmentCount do - #add the file to the hash - outputFile = "/tmp/#{Time.now.to_i}-#{rand(200)}-" + params["attachment-#{i}"][:filename] + tFile = Tempfile.new(['', params["attachment-#{i}"][:filename]]) + data = params["attachment-#{i}"][:tempfile].read() + tFile.write(data) + tFile.close() + + # use the whole file to ensure GC cannot release it yet + attachmentFiles.push(tFile) - File.open(outputFile, "w") do |f| - f.write(params["attachment-#{i}"][:tempfile].read) + i += 1 + end + + if params['body-plain'] + data = params['body-plain'] + bodyRegex = getBodyRegex() + if bodyRegex.nil? or bodyRegex.match(data) + tFile = Tempfile.new(['', 'email-body.txt']) + tFile.write(data) + tFile.close() + + # use the whole file to ensure GC cannot release it yet + attachmentFiles.push(tFile) + else + return logAndResponse(401, "body not accepted", logger) end + end - files.push(outputFile) + sendFax(sender, recipient, attachmentFiles) - i += 1 + attachmentFiles.each do |attachmentFile| + begin + attachmentFile.unlink() + rescue + # do nothing + end end - sendFax(params['sender'], params['recipient'],files) - "OK" + [200, "OK"] end -get '/sendgrid' do - [501, "sendgrid not implemented yet"] +def logAndResponse(responseCode, message, logger) + logger.info(message) + return [responseCode, message] end -def sendFax(fromEmail, toEmail, filenames) +def sendFax(fromEmail, toEmail, attachmentFiles) Phaxio.config do |config| config.api_key = ENV["PHAXIO_KEY"] config.api_secret = ENV["PHAXIO_SECRET"] @@ -67,18 +175,18 @@ def sendFax(fromEmail, toEmail, filenames) options = {to: number, callback_url: "mailto:#{fromEmail}" } - filenames.each_index do |idx| - options["filename[#{idx}]"] = File.new(filenames[idx]) + attachmentFiles.each_index do |idx| + options["filename[#{idx}]"] = File.new(attachmentFiles[idx].path) end - logger.info "#{fromEmail} is attempting to send #{filenames.length} files to #{number}..." + logger.info("#{fromEmail} is attempting to send #{attachmentFiles.length} files to #{number}...") result = Phaxio.send_fax(options) - result = JSON.parse result.body + result = JSON.parse(result.body) if result['success'] - logger.info "Fax queued up successfully: ID #" + result['data']['faxId'].to_s + logger.info("Fax queued up successfully: ID #" + result['data']['faxId'].to_s) else - logger.warn "Problem submitting fax: " + result['message'] + logger.warn("Problem submitting fax: " + result['message']) if ENV['SMTP_HOST'] #send mail back to the user telling them there was a problem @@ -87,7 +195,7 @@ def sendFax(fromEmail, toEmail, filenames) :to => fromEmail, :from => (ENV['SMTP_FROM'] || 'mailphax@example.com'), :subject => 'Mailfax: There was a problem sending your fax', - :body => "There was a problem faxing your #{filenames.length} files to #{number}: " + result['message'], + :body => "There was a problem faxing your #{attachmentFiles.length} files to #{number}: " + result['message'], :via => :smtp, :via_options => { :address => ENV['SMTP_HOST'], diff --git a/start_mailphax_server.sh.template b/start_mailphax_server.sh.template new file mode 100755 index 0000000..464778c --- /dev/null +++ b/start_mailphax_server.sh.template @@ -0,0 +1,16 @@ +#!/bin/bash + +source /usr/local/rvm/scripts/rvm + +PORT=8080 +HOST='0.0.0.0' +ENVIRONMENT='production' + +export PHAXIO_KEY='##PHAXIO API KEY##' +export PHAXIO_SECRET='##PHAXIO SECRET KEY##' +export MAILGUN_KEY='##MAILGUN API KEY##' + +# see app.json for more available environment variables + +cd /path/to/mailphax/git/repo/ +rackup -p $PORT -o $HOST -E $ENVIRONMENT