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