~singpolyma/jmp-pay

9de5399025b2d0be979478cfe804d04a92b7009c — Stephen Paul Weber 8 months ago 91fb350 + 507a409
Merge branch 'interac'

* interac:
  Interac Email Processor
  Allow Running BlatherNotify in Reactor
  Add PubSub Helpers to BlatherNotify
  Resync Guix Dependencies with Gemfile
  Fixup Correct Duplicate Addrs
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"

M bin/correct_duplicate_addrs => bin/correct_duplicate_addrs +1 -1
@@ 28,7 28,7 @@ $stdin.each_line do |line|
	end

	addr = match[1]
	keys = match[2].split(" ")
	keys = match[2].split

	# This is the customer ID of the support chat
	# All duplicates are moved to the support addr so we still hear when people

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 guix.scm => guix.scm +1 -0
@@ 950,6 950,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)))

M lib/blather_notify.rb => lib/blather_notify.rb +64 -10
@@ 7,24 7,64 @@ require "timeout"
module BlatherNotify
	extend Blather::DSL

	class PubSub
		class Address
			attr_reader :node, :server

			def initialize(node:, server:)
				@node = node
				@server = server
			end

			def to_uri
				"xmpp:#{@server}?;node=#{@node}"
			end
		end

		def initialize(blather, addr)
			@blather = blather
			@addr = addr
		end

		def publish(xml)
			@blather.write_with_promise(
				Blather::Stanza::PubSub::Publish.new(
					@addr.server,
					@addr.node,
					:set,
					xml
				)
			)
		end
	end

	@ready = Queue.new

	when_ready { @ready << :ready }

	def self.start(jid, password)
	def self.start(jid, password, default_pubsub_addr: nil)
		# workqueue_count MUST be 0 or else Blather uses threads!
		setup(jid, password, nil, nil, nil, nil, workqueue_count: 0)
		set_default_pubsub(default_pubsub_addr)

		EM.error_handler(&method(:panic))

		@thread = Thread.new {
			EM.run do
				client.run
			end
		}
		EM.next_tick { client.run }

		block_until_ready
	end

		Timeout.timeout(30) { @ready.pop }
		at_exit { wait_then_exit }
	def self.block_until_ready
		if EM.reactor_running?
			promise = EMPromise.new
			disconnected { true.tap { EM.next_tick { EM.stop } } }
			Thread.new { promise.fulfill(@ready.pop) }
			timeout_promise(promise, timeout: 30)
		else
			@thread = Thread.new { EM.run }
			Timeout.timeout(30) { @ready.pop }
			at_exit { wait_then_exit }
		end
	end

	def self.panic(e)


@@ 37,11 77,11 @@ module BlatherNotify
		disconnected { EM.stop }
		EM.add_timer(30) { EM.stop }
		shutdown
		@thread.join
		@thread&.join
	end

	def self.timeout_promise(promise, timeout: 15)
		timer = EM.add_timer(timeout) {
		timer = EventMachine::Timer.new(timeout) {
			promise.reject(:timeout)
		}



@@ 81,4 121,18 @@ module BlatherNotify
			write_with_promise(command(command_node, iq.sessionid, form: form))
		end
	end

	def self.pubsub(addr)
		PubSub.new(self, addr)
	end

	def self.set_default_pubsub(addr)
		@default_pubsub = addr && pubsub(addr)
	end

	def self.publish(xml)
		raise "No default pubsub set!" unless @default_pubsub

		@default_pubsub.publish(xml)
	end
end

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