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		NoMessage = err { |auto|
 42			"No Message in $%0.2f transfer %s from %s" %
 43				[
 44					auto.dollars,
 45					auto.transaction_id,
 46					auto.sender_name
 47				]
 48		}
 49		NoJID = err { |auto|
 50			"No JID in $%0.2f transfer %s from %s with message: %s" %
 51				[
 52					auto.dollars,
 53					auto.transaction_id,
 54					auto.sender_name,
 55					auto.message
 56				]
 57		}
 58
 59		MultipleJID = err { |auto|
 60			"Multiple JIDs in $%0.2f transfer %s from %s with message: %s" %
 61				[
 62					auto.dollars,
 63					auto.transaction_id,
 64					auto.sender_name,
 65					auto.message
 66				]
 67		}
 68	end
 69
 70	AUTO_REGEX =
 71		/and it has been automatically deposited.$/
 72		.freeze
 73
 74	def self.for(m, id_builder: ->(id) { id.to_s })
 75		Validator.new(m).validate!
 76		(m.subject =~ AUTO_REGEX ? AutomaticEmail : ManualEmail)
 77			.new(m, id_builder)
 78	end
 79
 80	def initialize(m, id_builder)
 81		@m = m
 82		@id_builder = id_builder
 83	end
 84
 85	class Validator
 86		INTERAC_SENDERS = [
 87			"notify@payments.interac.ca",
 88			"catch@payments.interac.ca"
 89		].freeze
 90
 91		def initialize(m)
 92			@m = m
 93		end
 94
 95		def validate!
 96			ensure_relevant
 97			ensure_safe
 98		end
 99
100		def ensure_relevant
101			raise Error::NoFrom, @m unless @m.from
102			raise Error::MultipleFrom, @m unless @m.from.length == 1
103			raise Error::BadSender, @m \
104				unless INTERAC_SENDERS.include?(@m.from.first)
105		end
106
107		def ensure_safe
108			# ensure_spam_checks
109			ensure_authentication_header
110			ensure_dkim
111		end
112
113		def spam_header
114			@m["X-Spam-Status"]
115				&.value
116				&.match(/tests=([^ ]*) /)
117				&.[](1)
118				&.split(/[,\t]+/)
119		end
120
121		def ensure_spam_checks
122			spam = spam_header
123
124			raise Error::NoSpam, @m unless spam
125			raise Error::BadSPF, @m unless spam.include?("SPF_PASS")
126			raise Error::BadDKIM, @m unless spam.include?("DKIM_VALID_AU")
127		end
128
129		def authentication_header
130			Array(@m["Authentication-Results"]).map(&:value)
131		end
132
133		HEADER_REGEX = /\sheader.d=payments.interac.ca\s/.freeze
134
135		def ensure_authentication_header
136			auth = authentication_header.find { |a|
137				a =~ HEADER_REGEX
138			}
139			raise Error::NoAuth, @m unless auth
140			raise Error::BadAuth, @m unless auth =~ /\sdkim=pass\s/
141		end
142
143		def dkim_headers
144			# Apparently there can sometimes be multiple DKIM sigs, and this
145			# library returns a scalar if there's one, or array otherwise.
146			# This Array method, when passed `nil` even returns an emtpy list!
147			Array(@m["DKIM-Signature"]).map { |s|
148				s.value
149				&.split(/;\s*/)
150				&.each_with_object({}) { |f, h|
151					k, v = f.split("=", 2)
152					h[k.to_sym] = v
153				}
154			}
155		end
156
157		def ensure_dkim
158			dkim = dkim_headers
159
160			raise Error::NoDKIM, @m if dkim.empty?
161
162			d = "payments.interac.ca"
163			raise Error::WrongDKIM, @m unless dkim.any? { |v| v[:d] == d }
164		end
165	end
166
167	def sender_name
168		@m["From"].display_names.first
169	end
170
171	def transaction_id
172		@m["X-PaymentKey"]&.value.tap { |v|
173			raise Error::NoTxnID, self unless v
174		}
175	end
176
177	def text_part
178		@m.text_part.tap { |v| raise Error::NoText, self unless v }
179	end
180
181	# First one is "Hi WHOEVER"
182	# Second one is "Funds Deposited!\n$NN.NN"
183	# Third is funds automatically deposited
184	# Fourth is bank account details
185	# Fifth is "Transfer Details"
186	# Sixth is "Message:\n<MESSAGE>"
187	# Seventh is a block of data with date, reference number, sender, amount
188	# Then there's a few different ones of footer
189	def paragraphs
190		# This needs to be a non-capturing group "(?:"
191		# Split does a neat thing with groups where it puts
192		# the matching groups into the returned list!
193		# Neat, but absolutely not what I want
194		text_part.decoded.strip.split(/(?:\r\n|\n){2,}/).tap { |v|
195			# I don't really use the others, but make sure the
196			# first three are there
197			raise Error::BadParagraphs, self unless v.length > 6
198		}
199	end
200
201	def message
202		# I used to hard-code a number, but it differs a bit.
203		# I figure this is safe because unlike the other fields, this is the
204		# user-provided one. So they can't really spoof themselves. It's the
205		# other fields I'd be more worried about
206		ms = paragraphs.select { |p| p.start_with?("Message:") }
207		raise Error::NoMessage, self if ms.empty?
208
209		ms.first.sub(/^Message:\s*/, "")
210	end
211
212	DOLLARS_REGEX = /
213		# This is a free-spaced regex, so literal spaces
214		# don't count as spaces in match
215		Funds\s+ Deposited!\s+\$([^ ]*)
216	/x.freeze
217
218	def raw_dollars
219		paragraphs[1].match(DOLLARS_REGEX)&.[](1).tap { |v|
220			raise Error::NoMoney, self unless v
221		}
222	end
223
224	def dollars
225		raw_dollars.delete(",").to_d.tap { |v|
226			raise Error::BadMoney, self unless v.positive?
227		}
228	end
229
230	def xmpp_id
231		@id_builder.call(transaction_id)
232	end
233
234	def author_xml(xml)
235		xml.name sender_name
236	end
237
238	def build_xml(xml)
239		xml.updated @m.date.iso8601
240		xml.id xmpp_id
241		xml.generator "interac_email", version: "1.0"
242		xml.author do
243			author_xml(xml)
244		end
245		xml.price ("%0.4f" % dollars), xmlns: "https://schema.org"
246		xml.priceCurrency "CAD", xmlns: "https://schema.org"
247		xml.content to_s, type: "text"
248	end
249
250	def to_xml
251		Nokogiri::XML::Builder.new { |xml|
252			xml.entry(xmlns: "http://www.w3.org/2005/Atom") do
253				build_xml(xml)
254			end
255		}.doc.root
256	end
257
258	def process
259		BlatherNotify.publish "#{transaction_id}": to_xml
260	end
261
262	class AutomaticEmail < self
263		def jids
264			message.scan(/[a-zA-Z0-9_.-]+@[a-zA-Z0-9_.-]+/).tap { |v|
265				raise Error::NoJID, self if v.empty?
266			}
267		end
268
269		def jid
270			raise Error::MultipleJID, self unless jids.length == 1
271
272			jids.first.downcase
273		end
274
275		def to_s
276			"$%0.2f received for %s" % [
277				dollars,
278				jid
279			]
280		end
281
282		def author_xml(xml)
283			super
284			xml.uri "xmpp:#{jid}"
285		end
286
287		def build_xml(xml)
288			super
289			xml.title "💸 Interac E-Transfer #{transaction_id}"
290			xml.category term: "interac_automatic", label: "Automatic Interac"
291		end
292	end
293
294	class ManualEmail < self
295		# We don't want people's names to be able to spoof a number,
296		# so consume greedily from the start
297		REGEX = /
298			# This is a free-spaced regex, so literal spaces
299			# don't count as spaces in match
300			Your\s funds\s await!\s+
301			\$([^ ]*)
302		/x.freeze
303
304		def raw_dollars
305			paragraphs[1].match(REGEX)&.[](1).tap { |v|
306				raise Error::NoMoney, self unless v
307			}
308		end
309
310		def raw_link
311			paragraphs[3]&.delete_prefix(
312				"RBC Royal Bank: "
313			)
314		end
315
316		def link
317			raw_link || "Couldn't find link"
318		end
319
320		def to_s
321			"A manual E-Transfer has been received from \"%s\" for " \
322			"$%0.2f. Message: %s\nTo deposit go to %s" % [
323				sender_name,
324				dollars,
325				message,
326				link
327			]
328		end
329
330		def build_xml(xml)
331			super
332			xml.title "⚠️ Manual #{transaction_id}"
333			xml.link href: raw_link, type: "text/html" if raw_link
334			xml.category term: "interac_manual", label: "Manual Interac"
335		end
336	end
337end