interac_email.rb

  1# frozen_string_literal: true
  2
  3require "bigdecimal/util"
  4require "nokogiri"
  5
  6class InteracEmail
  7	class Error < StandardError
  8		def self.err(str=nil, &block)
  9			Class.new(self).tap do |klass|
 10				klass.define_method("initialize") do |m|
 11					super(block ? block.call(m) : str)
 12				end
 13			end
 14		end
 15
 16		# The `m` in these contexts is the raw mail from the library
 17		NoFrom = err "No 'From' (probably isn't an email)"
 18		MultipleFrom = err { |m| "More than 1 'From' #{m.from}" }
 19		BadSender = err "Email isn't from Interac"
 20
 21		NoSpam = err "No Spam Status"
 22		BadSPF = err "Don't trust SPF"
 23		NoAuth = err "Authentication header missing"
 24		BadAuth = err "Authentication header isn't a pass"
 25		BadDomain = err "Authentication header isn't for the right domain"
 26		BadDKIM = err "Don't trust DKIM"
 27		NoDKIM = err "No DKIM Signature somehow..."
 28		WrongDKIM = err "DKIM Signature is for a different domain"
 29
 30		# From here, the m in the error is assumed to be an instance rather than
 31		# the underlying email object
 32
 33		NoTxnID = err "No Transaction ID"
 34		NoTxt = err "No text part"
 35		BadParagraphs = err "Paragraph structure seems off"
 36		NoMoney = err { |auto|
 37			"Couldn't find money in \"#{auto.paragraphs[1]}\""
 38		}
 39		BadMoney = err { |auto| "Dollars aren't dollars (#{auto.raw_dollars})" }
 40
 41		NoJID = err { |auto|
 42			"No JID in $%0.2f transfer %s from %s with message: %s" %
 43				[
 44					auto.dollars,
 45					auto.transaction_id,
 46					auto.sender_name,
 47					auto.message
 48				]
 49		}
 50
 51		MultipleJID = err { |auto|
 52			"Multiple JIDs in $%0.2f transfer %s from %s with message: %s" %
 53				[
 54					auto.dollars,
 55					auto.transaction_id,
 56					auto.sender_name,
 57					auto.message
 58				]
 59		}
 60	end
 61
 62	AUTO_REGEX =
 63		/A +money +transfer +from .* has +been +automatically +deposited.$/
 64		.freeze
 65
 66	def self.for(m, id_builder: ->(id) { id.to_s })
 67		Validator.new(m).validate!
 68		(m.subject =~ AUTO_REGEX ? AutomaticEmail : ManualEmail)
 69			.new(m, id_builder)
 70	end
 71
 72	def initialize(m, id_builder)
 73		@m = m
 74		@id_builder = id_builder
 75	end
 76
 77	class Validator
 78		INTERAC_SENDERS = [
 79			"notify@payments.interac.ca",
 80			"catch@payments.interac.ca"
 81		].freeze
 82
 83		def initialize(m)
 84			@m = m
 85		end
 86
 87		def validate!
 88			ensure_relevant
 89			ensure_safe
 90		end
 91
 92		def ensure_relevant
 93			raise Error::NoFrom, @m unless @m.from
 94			raise Error::MultipleFrom, @m unless @m.from.length == 1
 95			raise Error::BadSender, @m \
 96				unless INTERAC_SENDERS.include?(@m.from.first)
 97		end
 98
 99		def ensure_safe
100			# ensure_spam_checks
101			ensure_authentication_header
102			ensure_dkim
103		end
104
105		def spam_header
106			@m["X-Spam-Status"]
107				&.value
108				&.match(/tests=([^ ]*) /)
109				&.[](1)
110				&.split(/[,\t]+/)
111		end
112
113		def ensure_spam_checks
114			spam = spam_header
115
116			raise Error::NoSpam, @m unless spam
117			raise Error::BadSPF, @m unless spam.include?("SPF_PASS")
118			raise Error::BadDKIM, @m unless spam.include?("DKIM_VALID_AU")
119		end
120
121		def authentication_header
122			Array(@m["Authentication-Results"]).map(&:value)
123		end
124
125		HEADER_REGEX = /\sheader.d=payments.interac.ca\s/.freeze
126
127		def ensure_authentication_header
128			auth = authentication_header.find { |a|
129				a =~ HEADER_REGEX
130			}
131			raise Error::NoAuth, @m unless auth
132			raise Error::BadAuth, @m unless auth =~ /\sdkim=pass\s/
133		end
134
135		def dkim_header
136			@m["DKIM-Signature"]
137				&.value
138				&.split(/;\s*/)
139				&.each_with_object({}) { |f, h|
140					k, v = f.split("=", 2)
141					h[k.to_sym] = v
142				}
143		end
144
145		def ensure_dkim
146			dkim = dkim_header
147
148			raise Error::DKIM, @m unless dkim
149			raise Error::WrongDKIM, @m unless dkim[:d] == "payments.interac.ca"
150		end
151	end
152
153	def sender_name
154		@m["From"].display_names.first
155	end
156
157	def transaction_id
158		@m["X-PaymentKey"]&.value.tap { |v|
159			raise Error::NoTxnID, self unless v
160		}
161	end
162
163	def text_part
164		@m.text_part.tap { |v| raise Error::NoText, self unless v }
165	end
166
167	# First one is "Hi WHOEVER"
168	# Second one is "So and so sent you this much"
169	# Third is the message
170	# Fourth is Reference number
171	# Fifth is "Do not reply"
172	# Sixth is footer
173	def paragraphs
174		# This needs to be a non-capturing group "(?:"
175		# Split does a neat thing with groups where it puts
176		# the matching groups into the returned list!
177		# Neat, but absolutely not what I want
178		text_part.decoded.split(/(?:\r\n){2,}/).tap { |v|
179			# I don't really use the others, but make sure the
180			# first three are there
181			raise Error::BadParagraphs, self unless v.length > 3
182		}
183	end
184
185	def message
186		paragraphs[2].sub(/^Message:\s*/, "")
187	end
188
189	# We don't want people's names to be able to spoof a number,
190	# so consume greedily from the start
191	DOLLARS_REGEX = /
192		# This is a free-spaced regex, so literal spaces
193		# don't count as spaces in match
194		.*\s+ has\s+ sent\s+ you\s+ a\s+ money\s+ transfer\s+ for\s+ the\s+
195		amount\s+ of\s+ \$([^ ]*)\s+ \(CAD\)\s+ and\s+ the\s+ money\s+ has\s+
196		been\s+ automatically\s+ deposited\s+ into\s+ your\s+ bank\s+ account
197	/x.freeze
198
199	def raw_dollars
200		paragraphs[1].match(DOLLARS_REGEX)&.[](1).tap { |v|
201			raise Error::NoMoney, self unless v
202		}
203	end
204
205	def dollars
206		raw_dollars.delete(",").to_d.tap { |v|
207			raise Error::BadMoney, self unless v.positive?
208		}
209	end
210
211	def xmpp_id
212		@id_builder.call(transaction_id)
213	end
214
215	def author_xml(xml)
216		xml.name sender_name
217	end
218
219	def build_xml(xml)
220		xml.updated @m.date.iso8601
221		xml.id xmpp_id
222		xml.generator "interac_email", version: "1.0"
223		xml.author do
224			author_xml(xml)
225		end
226		xml.price ("%0.4f" % dollars), xmlns: "https://schema.org"
227		xml.priceCurrency "CAD", xmlns: "https://schema.org"
228		xml.content to_s, type: "text"
229	end
230
231	def to_xml
232		Nokogiri::XML::Builder.new { |xml|
233			xml.entry(xmlns: "http://www.w3.org/2005/Atom") do
234				build_xml(xml)
235			end
236		}.doc.root
237	end
238
239	def process
240		BlatherNotify.publish "#{transaction_id}": to_xml
241	end
242
243	class AutomaticEmail < self
244		def jids
245			message.scan(/[a-zA-Z0-9_.-]+@[a-zA-Z0-9_.-]+/).tap { |v|
246				raise Error::NoJID, self if v.empty?
247			}
248		end
249
250		def jid
251			raise Error::MultipleJID, self unless jids.length == 1
252
253			jids.first.downcase
254		end
255
256		def to_s
257			"$%0.2f received for %s" % [
258				dollars,
259				jid
260			]
261		end
262
263		def author_xml(xml)
264			super
265			xml.uri "xmpp:#{jid}"
266		end
267
268		def build_xml(xml)
269			super
270			xml.title "💸 Interac E-Transfer #{transaction_id}"
271			xml.category term: "interac_automatic", label: "Automatic Interac"
272		end
273	end
274
275	class ManualEmail < self
276		# We don't want people's names to be able to spoof a number,
277		# so consume greedily from the start
278		REGEX = /
279			# This is a free-spaced regex, so literal spaces
280			# don't count as spaces in match
281			.*\s+ sent\s+ you\s+ a\s+ money\s+ transfer\s+ for\s+ the\s+
282			amount\s+ of\s+ \$([^ ]*)\s+ \(CAD\).
283		/x.freeze
284
285		def raw_dollars
286			paragraphs[1].match(REGEX)&.[](1).tap { |v|
287				raise Error::NoMoney, self unless v
288			}
289		end
290
291		def raw_link
292			paragraphs[4]&.delete_prefix(
293				"To deposit your money, click here:\r\n"
294			)
295		end
296
297		def link
298			raw_link || "Couldn't find link"
299		end
300
301		def to_s
302			"A manual E-Transfer has been received from \"%s\" for "\
303			"$%0.2f. Message: %s\nTo deposit go to %s" % [
304				sender_name,
305				dollars,
306				message,
307				link
308			]
309		end
310
311		def build_xml(xml)
312			super
313			xml.title "⚠️ Manual #{transaction_id}"
314			xml.link href: raw_link, type: "text/html" if raw_link
315			xml.category term: "interac_manual", label: "Manual Interac"
316		end
317	end
318end