Merge branch 'interac'

Stephen Paul Weber created

* interac:
  Interac Email Processor
  Allow Running BlatherNotify in Reactor
  Add PubSub Helpers to BlatherNotify
  Resync Guix Dependencies with Gemfile
  Fixup Correct Duplicate Addrs

Change summary

Gemfile                     |   1 
bin/correct_duplicate_addrs |   2 
bin/process_interac_email   |  62 ++++++++
guix.scm                    |   1 
lib/blather_notify.rb       |  74 ++++++++-
lib/interac_email.rb        | 300 +++++++++++++++++++++++++++++++++++++++
6 files changed, 429 insertions(+), 11 deletions(-)

Detailed changes

Gemfile 🔗

@@ -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"

bin/correct_duplicate_addrs 🔗

@@ -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

bin/process_interac_email 🔗

@@ -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

guix.scm 🔗

@@ -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)))

lib/blather_notify.rb 🔗

@@ -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

lib/interac_email.rb 🔗

@@ -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