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"
Christopher Vollick created
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.
Gemfile | 1
bin/process_interac_email | 62 ++++++++
jmp-pay.scm | 1
lib/interac_email.rb | 300 +++++++++++++++++++++++++++++++++++++++++
4 files changed, 364 insertions(+)
@@ -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"
@@ -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
@@ -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)))
@@ -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