From 507a4094d853b39bdb59841288865c4d2e66caff Mon Sep 17 00:00:00 2001 From: Christopher Vollick <0@psycoti.ca> Date: Mon, 23 Jan 2023 18:02:36 -0500 Subject: [PATCH] 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. --- Gemfile | 1 + bin/process_interac_email | 62 ++++++++ jmp-pay.scm | 1 + lib/interac_email.rb | 300 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 364 insertions(+) create mode 100755 bin/process_interac_email create mode 100644 lib/interac_email.rb diff --git a/Gemfile b/Gemfile index ca692e20b542b76b5f530430e3e4d4765de0bc2b..27a54190605efab798e8d8b5b2b07fdd62735d62 100644 --- a/Gemfile +++ b/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" diff --git a/bin/process_interac_email b/bin/process_interac_email new file mode 100755 index 0000000000000000000000000000000000000000..02d087149cdd819554b0862a8ad17d87425c1bba --- /dev/null +++ b/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 diff --git a/jmp-pay.scm b/jmp-pay.scm index 901dde01081cdbd53ba9bdfc90f0ed54a46d6374..43f3085812580deca3d0e53d9e769ee809609636 100644 --- a/jmp-pay.scm +++ b/jmp-pay.scm @@ -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))) diff --git a/lib/interac_email.rb b/lib/interac_email.rb new file mode 100644 index 0000000000000000000000000000000000000000..b7212529f9295b2186fc924ce30c4afcf011fcf4 --- /dev/null +++ b/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