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