~singpolyma/jmp-pay

507a4094d853b39bdb59841288865c4d2e66caff — Christopher Vollick 8 months ago 1128cbd
Interac Email Processor

This script is expected to be run by piping an email into it and also
giving a dhall config as the first argument. This will contain the JID
and password to connect as, and the pubsub_node to dump the outcome to.

It doesn't write anything directly, it just produces Atom into the
pubsub channel, and the expectation is that some other process will do
something with it; either display the message or the actual handling of
the transaction.

The Email parsing is intentionally very defensive, because the
expectation is that whatever passes this parsing gets turned into credit
in a user's account, so we want to make sure it's all above-board and
bail early if something looks off. It's better to have to manually do
something than to have it do too much on its own.

We've tried to integrate the transaction values into atom as best as we
can, and we've pulled in schema.org for the few things that didn't have
a correlation.
4 files changed, 364 insertions(+), 0 deletions(-)

M Gemfile
A bin/process_interac_email
M jmp-pay.scm
A lib/interac_email.rb
M Gemfile => Gemfile +1 -0
@@ 8,6 8,7 @@ gem "dhall", ">= 0.5.3.fixed"
gem "em-http-request"
gem "em_promise.rb"
gem "em-synchrony"
gem "mail"
gem "money-open-exchange-rates"
gem "pg"
gem "redis"

A bin/process_interac_email => bin/process_interac_email +62 -0
@@ 0,0 1,62 @@
#!/usr/bin/ruby
# frozen_string_literal: true

# This expects postfix to be piping an email into stdin

require "dhall"
require "mail"
require "nokogiri"
require "securerandom"
require "sentry-ruby"
require "time"

require_relative "../lib/blather_notify"
require_relative "../lib/interac_email"

def error_entry(title, text, id)
	Nokogiri::XML::Builder.new { |xml|
		xml.entry(xmlns: "http://www.w3.org/2005/Atom") do
			xml.updated DateTime.now.iso8601
			xml.id id
			xml.title title
			xml.content text.to_s, type: "text"
			xml.author { xml.name "interac_email" }
			xml.generator "interac_email", version: "1.0"
		end
	}.doc.root
end

raise "Need a Dhall config" unless ARGV[0]

# I shift here because ARGF will work with either stdin or a file in argv if
# there are any
CONFIG =
	Dhall::Coder
	.new(safe: Dhall::Coder::JSON_LIKE + [Symbol, Proc])
	.load(ARGV.shift, transform_keys: :to_sym)

pubsub = BlatherNotify::PubSub::Address.new(**CONFIG[:pubsub])

m = Mail.new(ARGF.read)
id_builder = ->(id) { "#{pubsub.to_uri};item=#{id}" }

EM.run do
	BlatherNotify.start(
		CONFIG[:jid],
		CONFIG[:password],
		default_pubsub_addr: pubsub
	).then {
		InteracEmail.for(m, id_builder: id_builder).process
	}.catch_only(InteracEmail::Error) { |e|
		uuid = SecureRandom.uuid
		BlatherNotify.publish "#{uuid}":
			error_entry("💥 Exception #{e.class}", e, id_builder.call(uuid))
	}.catch { |e|
		if e.is_a?(::Exception)
			Sentry.capture_exception(e)
		else
			Sentry.capture_message(e.to_s)
		end
		puts e
	}.then { BlatherNotify.shutdown }
end

M jmp-pay.scm => jmp-pay.scm +1 -0
@@ 936,6 936,7 @@
        ("ruby-em-synchrony" ,ruby-em-synchrony)
        ("ruby-em-http-request" ,ruby-em-http-request)
        ("ruby-bandwidth-iris" ,ruby-bandwidth-iris)
        ("ruby-mail" ,ruby-mail)
        ("ruby-sentry" ,ruby-sentry)
        ("ruby" ,ruby) ;; Normally ruby-build-system adds this for us
        ("ruby-slim" ,ruby-slim)))

A lib/interac_email.rb => lib/interac_email.rb +300 -0
@@ 0,0 1,300 @@
# frozen_string_literal: true

require "bigdecimal/util"
require "nokogiri"

class InteracEmail
	class Error < StandardError
		def self.err(str=nil, &block)
			Class.new(self).tap do |klass|
				klass.define_method("initialize") do |m|
					super(block ? block.call(m) : str)
				end
			end
		end

		# The `m` in these contexts is the raw mail from the library
		NoFrom = err "No 'From' (probably isn't an email)"
		MultipleFrom = err { |m| "More than 1 'From' #{m.from}" }
		BadSender = err "Email isn't from Interac"

		NoSpam = err "No Spam Status"
		BadSPF = err "Don't trust SPF"
		BadDKIM = err "Don't trust DKIM"
		NoDKIM = err "No DKIM Signature somehow..."
		WrongDKIM = err "DKIM Signature is for a different domain"

		# From here, the m in the error is assumed to be an instance rather than
		# the underlying email object

		NoTxnID = err "No Transaction ID"
		NoTxt = err "No text part"
		BadParagraphs = err "Paragraph structure seems off"
		NoMoney = err { |auto|
			"Couldn't find money in \"#{auto.paragraphs[1]}\""
		}
		BadMoney = err { |auto| "Dollars aren't dollars (#{auto.raw_dollars})" }

		NoJID = err { |auto|
			"No JID in $%0.2f transfer %s from %s with message: %s" %
				[
					auto.dollars,
					auto.transaction_id,
					auto.sender_name,
					auto.message
				]
		}

		MultipleJID = err { |auto|
			"Multiple JIDs in $%0.2f transfer %s from %s with message: %s" %
				[
					auto.dollars,
					auto.transaction_id,
					auto.sender_name,
					auto.message
				]
		}
	end

	AUTO_REGEX =
		/A +money +transfer +from .* has +been +automatically +deposited.$/
		.freeze

	def self.for(m, id_builder: ->(id) { id.to_s })
		Validator.new(m).validate!
		(m.subject =~ AUTO_REGEX ? AutomaticEmail : ManualEmail)
			.new(m, id_builder)
	end

	def initialize(m, id_builder)
		@m = m
		@id_builder = id_builder
	end

	class Validator
		INTERAC_SENDERS = [
			"notify@payments.interac.ca",
			"catch@payments.interac.ca"
		].freeze

		def initialize(m)
			@m = m
		end

		def validate!
			ensure_relevant
			ensure_safe
		end

		def ensure_relevant
			raise Error::NoFrom, @m unless @m.from
			raise Error::MultipleFrom, @m unless @m.from.length == 1
			raise Error::BadSender, @m \
				unless INTERAC_SENDERS.include?(@m.from.first)
		end

		def ensure_safe
			ensure_spam_checks
			ensure_dkim
		end

		def spam_header
			@m["X-Spam-Status"]
				&.value
				&.match(/tests=([^ ]*) /)
				&.[](1)
				&.split(/[,\t]+/)
		end

		def ensure_spam_checks
			spam = spam_header

			raise Error::NoSpam, @m unless spam
			raise Error::BadSPF, @m unless spam.include?("SPF_PASS")
			raise Error::BadDKIM, @m unless spam.include?("DKIM_VALID_AU")
		end

		def dkim_header
			@m["DKIM-Signature"]
				&.value
				&.split(/;\s*/)
				&.each_with_object({}) { |f, h|
					k, v = f.split("=", 2)
					h[k.to_sym] = v
				}
		end

		def ensure_dkim
			dkim = dkim_header

			raise Error::DKIM, @m unless dkim
			raise Error::WrongDKIM, @m unless dkim[:d] == "payments.interac.ca"
		end
	end

	def sender_name
		@m["From"].display_names.first
	end

	def transaction_id
		@m["X-PaymentKey"]&.value.tap { |v|
			raise Error::NoTxnID, self unless v
		}
	end

	def text_part
		@m.text_part.tap { |v| raise Error::NoText, self unless v }
	end

	# First one is "Hi WHOEVER"
	# Second one is "So and so sent you this much"
	# Third is the message
	# Fourth is Reference number
	# Fifth is "Do not reply"
	# Sixth is footer
	def paragraphs
		# This needs to be a non-capturing group "(?:"
		# Split does a neat thing with groups where it puts
		# the matching groups into the returned list!
		# Neat, but absolutely not what I want
		text_part.decoded.split(/(?:\r\n){2,}/).tap { |v|
			# I don't really use the others, but make sure the
			# first three are there
			raise Error::BadParagraphs, self unless v.length > 3
		}
	end

	def message
		paragraphs[2].sub(/^Message:\s*/, "")
	end

	# We don't want people's names to be able to spoof a number,
	# so consume greedily from the start
	DOLLARS_REGEX = /
		# This is a free-spaced regex, so literal spaces
		# don't count as spaces in match
		.*\s+ has\s+ sent\s+ you\s+ a\s+ money\s+ transfer\s+ for\s+ the\s+
		amount\s+ of\s+ \$([^ ]*)\s+ \(CAD\)\s+ and\s+ the\s+ money\s+ has\s+
		been\s+ automatically\s+ deposited\s+ into\s+ your\s+ bank\s+ account
	/x.freeze

	def raw_dollars
		paragraphs[1].match(DOLLARS_REGEX)&.[](1).tap { |v|
			raise Error::NoMoney, self unless v
		}
	end

	def dollars
		raw_dollars.delete(",").to_d.tap { |v|
			raise Error::BadMoney, self unless v.positive?
		}
	end

	def xmpp_id
		@id_builder.call(transaction_id)
	end

	def author_xml(xml)
		xml.name sender_name
	end

	def build_xml(xml)
		xml.updated @m.date.iso8601
		xml.id xmpp_id
		xml.generator "interac_email", version: "1.0"
		xml.author do
			author_xml(xml)
		end
		xml.price ("%0.4f" % dollars), xmlns: "https://schema.org"
		xml.priceCurrency "CAD", xmlns: "https://schema.org"
		xml.content to_s, type: "text"
	end

	def to_xml
		Nokogiri::XML::Builder.new { |xml|
			xml.entry(xmlns: "http://www.w3.org/2005/Atom") do
				build_xml(xml)
			end
		}.doc.root
	end

	def process
		BlatherNotify.publish "#{transaction_id}": to_xml
	end

	class AutomaticEmail < self
		def jids
			message.scan(/[a-zA-Z0-9_.-]+@[a-zA-Z0-9_.-]+/).tap { |v|
				raise Error::NoJID, self if v.empty?
			}
		end

		def jid
			raise Error::MultipleJID, self unless jids.length == 1

			jids.first.downcase
		end

		def to_s
			"$%0.2f received for %s" % [
				dollars,
				jid
			]
		end

		def author_xml(xml)
			super
			xml.uri "xmpp:#{jid}"
		end

		def build_xml(xml)
			super
			xml.title "💸 Interac E-Transfer #{transaction_id}"
			xml.category term: "interac_automatic", label: "Automatic Interac"
		end
	end

	class ManualEmail < self
		# We don't want people's names to be able to spoof a number,
		# so consume greedily from the start
		REGEX = /
			# This is a free-spaced regex, so literal spaces
			# don't count as spaces in match
			.*\s+ sent\s+ you\s+ a\s+ money\s+ transfer\s+ for\s+ the\s+
			amount\s+ of\s+ \$([^ ]*)\s+ \(CAD\).
		/x.freeze

		def raw_dollars
			paragraphs[1].match(REGEX)&.[](1).tap { |v|
				raise Error::NoMoney, self unless v
			}
		end

		def raw_link
			paragraphs[4]&.delete_prefix(
				"To deposit your money, click here:\r\n"
			)
		end

		def link
			raw_link || "Couldn't find link"
		end

		def to_s
			"A manual E-Transfer has been received from \"%s\" for "\
			"$%0.2f. Message: %s\nTo deposit go to %s" % [
				sender_name,
				dollars,
				message,
				link
			]
		end

		def build_xml(xml)
			super
			xml.title "⚠️ Manual #{transaction_id}"
			xml.link href: raw_link, type: "text/html" if raw_link
			xml.category term: "interac_manual", label: "Manual Interac"
		end
	end
end