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