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