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/correct_duplicate_addrs b/bin/correct_duplicate_addrs index 225cad9f5bdeb457d734bdfc4596f01b9ef7c81b..12e80a1425a47ede1c197be19a7f961ee5c004d6 100755 --- a/bin/correct_duplicate_addrs +++ b/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 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/guix.scm b/guix.scm index 7e7d0438c98ed5e03a0b18ca0582e82337369226..c4b222965d7de85fb032a8675991da1132987b68 100644 --- a/guix.scm +++ b/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))) diff --git a/lib/blather_notify.rb b/lib/blather_notify.rb index 81deb18cf6dfd88bf1a691ce2607a0c45168f26d..6c5716cd3655cd28e94184818cd0c611efbe7e83 100644 --- a/lib/blather_notify.rb +++ b/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 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