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