roda 3.15.0 → 3.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3a3a695566853202815ee8857b9f2276b06d97204c98b06985d2a78c1a535dab
4
- data.tar.gz: 700b5fbd7c73c2ed9a3f758ddae5476f1428659a39142ab2ce6b155c1cab197d
3
+ metadata.gz: 64aa07cd8d8fff3df32bb1ddb87089eb0248db3b21d76eb9d851bcebe48276af
4
+ data.tar.gz: 8865bb77e86f9671c60cd67bfbceb5b38761a9a662719d28dae6f2768ad07827
5
5
  SHA512:
6
- metadata.gz: cc207cd02ca21544f435e6a6d22420915087c1418ede8fe5a5468267e1df070354199c5d13b41a41c7900a20608b7ce020bb1af8bdcb495a2751b34a3826254b
7
- data.tar.gz: 2685a3c79454feb84de1c58137f8488f82f8b17f89f9f61973bd62ed0ce62ea460f91d871b4b80b83dca21ecb0e0cc073d6a54a148f098e845e8250f92d583cf
6
+ metadata.gz: 0c598bccca29ace44a814342f42e58c8fb81b0a445049281c334d00d9036fb2862db19438c61a288f6ec54d1ec65e2775719b81b3a7c42fa97f2e2dc09fe8895
7
+ data.tar.gz: 7c917b0ef0116b0fba649e3386c97f42e76316ed83601f405835fd46f13c43d5cd135292be236e0c1df75d206608075a150ce9fd3c407b77e129bbd69e379379
data/CHANGELOG CHANGED
@@ -1,3 +1,7 @@
1
+ = 3.16.0 (2019-01-18)
2
+
3
+ * Add mail_processor plugin for processing mail using a routing tree (jeremyevans)
4
+
1
5
  = 3.15.0 (2018-12-14)
2
6
 
3
7
  * Support render plugin :escape option to be a string or array of strings and only add :escape option for those template engines (jeremyevans) (#158)
@@ -0,0 +1,52 @@
1
+ = New Features
2
+
3
+ * A mail_processor plugin has been added for processing mail using
4
+ a routing tree. Quick example:
5
+
6
+ class MailProcessor < Roda
7
+ plugin :mail_processor
8
+
9
+ route do |r|
10
+ # Match based on the To header, extracting the ticket_id
11
+ r.to /ticket\+(\d+)@example.com/ do |ticket_id|
12
+ if ticket = Ticket[ticket_id.to_i]
13
+ # Mark the mail as handled if there is a valid ticket
14
+ # associated
15
+ r.handle do
16
+ ticket.add_note(text: mail_text, from: from)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+
23
+ You can submit mail for processing by calling the process_mail
24
+ method with a Mail instance:
25
+
26
+ MailProcessor.process_mail(Mail.read('/path/to/message.eml'))
27
+
28
+ The mail_processor routing tree uses routing methods specific to
29
+ mail:
30
+
31
+ r.from :: match on the mail From address
32
+ r.to :: match on the mail To address
33
+ r.cc :: match on the mail CC address
34
+ r.rcpt :: match on the mail recipients (To and CC addresses by
35
+ default)
36
+ r.subject :: match on the mail subject
37
+ r.body :: match on the mail body
38
+ r.text :: match on text extracted from the message (same as mail
39
+ body by default)
40
+ r.header :: match on a mail header
41
+
42
+ To mark a mail as having been handled, you call the r.handle method
43
+ with a block, or one of the above methods prefixed by handle_
44
+ (e.g. r.handle_text).
45
+
46
+ The mail_processor plugin supports hooks that are called for handled
47
+ mail, unhandled mail, and all mail (for archiving). It also
48
+ supports the ability to configure how reply text is parsed out of
49
+ mail, who to consider as the recipients of the email, and the
50
+ ability to have separate routing blocks per recipient email address
51
+ (with O(1) delegation to the appropriate block if the recipient
52
+ addresses to match is a string).
@@ -0,0 +1,620 @@
1
+ # frozen-string-literal: true
2
+
3
+ require 'mail'
4
+
5
+ class Roda
6
+ module RodaPlugins
7
+ # The mail_processor plugin allows your Roda application to process mail
8
+ # using a routing tree. Quick example:
9
+ #
10
+ # class MailProcessor < Roda
11
+ # plugin :mail_processor
12
+ #
13
+ # route do |r|
14
+ # # Match based on the To header, extracting the ticket_id
15
+ # r.to /ticket\+(\d+)@example.com/ do |ticket_id|
16
+ # if ticket = Ticket[ticket_id.to_i]
17
+ # # Mark the mail as handled if there is a valid ticket associated
18
+ # r.handle do
19
+ # ticket.add_note(text: mail_text, from: from)
20
+ # end
21
+ # end
22
+ # end
23
+ #
24
+ # # Match based on the To or CC header
25
+ # r.rcpt "post@example.com" do
26
+ # # Match based on the body, capturing the post id and tag
27
+ # r.body(/^Post: (\d+)-(\w+)/) do |post_id, tag|
28
+ # unhandled_mail("no matching post") unless post = Post[post_id.to_i]
29
+ # unhandled_mail("tag doesn't match for post") unless post.tag == tag
30
+ #
31
+ # # Match based on APPROVE somewhere in the mail text,
32
+ # # marking the mail as handled
33
+ # r.handle_text /\bAPPROVE\b/i do
34
+ # post.approve!(from)
35
+ # end
36
+ #
37
+ # # Match based on DENY somewhere in the mail text,
38
+ # # marking the mail as handled
39
+ # r.handle_text /\bDENY\b/i do
40
+ # post.deny!(from)
41
+ # end
42
+ # end
43
+ # end
44
+ # end
45
+ # end
46
+ #
47
+ # = Processing Mail
48
+ #
49
+ # To submit a mail for processing via the mail_processor routing tree, call the +process_mail+
50
+ # method with a +Mail+ instance:
51
+ #
52
+ # MailProcessor.process_mail(Mail.new do
53
+ # # ...
54
+ # end)
55
+ #
56
+ # You can use this to process mail messages from the filesystem:
57
+ #
58
+ # MailProcessor.process_mail(Mail.read('/path/to/message.eml'))
59
+ #
60
+ # If you have a service that delivers mail via an HTTP POST request (for realtime
61
+ # processing), you can have your web routes convert the web request into a +Mail+ instance
62
+ # and then call +process_mail+:
63
+ #
64
+ # r.post "email" do
65
+ # # check request is submitted by trusted sender
66
+ #
67
+ # # If request body is the raw mail body
68
+ # r.body.rewind
69
+ # MailProcessor.process_mail(Mail.new(r.body.read))
70
+ #
71
+ # # If request body is in a parameter named content
72
+ # MailProcessor.process_mail(Mail.new(r.params['content']))
73
+ #
74
+ # # If the HTTP request requires a specific response status code (such as 204)
75
+ # response.status = 204
76
+ #
77
+ # nil
78
+ # end
79
+ #
80
+ # Note that when receiving messages via HTTP, you need to make sure you check that the
81
+ # request is trusted. How to do this depends on the delivery service, but could involve
82
+ # using HTTP basic authentication, checking for valid API tokens, or checking that a message
83
+ # includes a signature/hash that matches the expected value.
84
+ #
85
+ # If you have setup a default retriever_method for +Mail+, you can call +process_mailbox+,
86
+ # which will process all mail in the given mailbox (using +Mail.find_and_delete+):
87
+ #
88
+ # MailProcessor.process_mailbox
89
+ #
90
+ # You can also use a +:retreiver+ option to provide a specific retriever:
91
+ #
92
+ # MailProcessor.process_mailbox(retreiver: Mail::POP3.new)
93
+ #
94
+ # = Routing Mail
95
+ #
96
+ # The mail_processor plugin handles routing similar to Roda's default routing for
97
+ # web requests, but because mail processing may not return a result, the mail_processor
98
+ # plugin uses a more explicit approach to consider whether the message has been handled.
99
+ # If the +r.handle+ method is called during routing, the mail is considered handled,
100
+ # otherwise the mail is considered not handled. The +unhandled_mail+ method can be
101
+ # called at any point to stop routing and consider the mail as not handled (even if
102
+ # inside an +r.handle+ block).
103
+ #
104
+ # Here are the mail routing methods and what they use for matching:
105
+ #
106
+ # from :: match on the mail From address
107
+ # to :: match on the mail To address
108
+ # cc :: match on the mail CC address
109
+ # rcpt :: match on the mail recipients (To and CC addresses by default)
110
+ # subject :: match on the mail subject
111
+ # body :: match on the mail body
112
+ # text :: match on text extracted from the message (same as mail body by default)
113
+ # header :: match on a mail header
114
+ #
115
+ # All of these routing methods accept a single argument, except for +r.header+, which
116
+ # can take two arguments.
117
+ #
118
+ # Each of these routing methods also has a +r.handle_*+ method
119
+ # (e.g. +r.handle_from+), which will call +r.handle+ implicitly to mark the
120
+ # mail as handled if the routing method matches and control is passed to the block.
121
+ #
122
+ # The address matchers (from, to, cc, rcpt) perform a case-insensitive match if
123
+ # given a string or array of strings, and a regular regexp match if given a regexp.
124
+ #
125
+ # The content matchers (subject, body, text) perform a case-sensitive substring search
126
+ # if given a string or array of strings, and a regular regexp match if given a regexp.
127
+ #
128
+ # The header matcher should be called with a key and an optional value. If the matcher is
129
+ # called with a key and not a value, it matches if a header matching the key is present
130
+ # in the message, yielding the header value. If the matcher is called with a key and a
131
+ # value, it matches if a header matching the key is present and the header value matches
132
+ # the value given, using the same criteria as the content matchers.
133
+ #
134
+ # In all cases for matchers, if a string is given and matches, the match block is called without
135
+ # arguments. If an array of strings is given, and one of the strings matches,
136
+ # the match block is called with the matching string argument. If a regexp is given,
137
+ # the match block is called with the regexp captures. This is the same behavior for Roda's
138
+ # general string, array, and regexp matchers.
139
+ #
140
+ # = Recipient-Specific Routing
141
+ #
142
+ # To allow splitting up the mail processor routing tree based on recipients, you can use
143
+ # the +rcpt+ class method, which takes any number of string or regexps arguments for recipient
144
+ # addresses, and a block to handle the routing for those addresses instead of using the
145
+ # default routing.
146
+ #
147
+ # MailProcessor.rcpt('a@example.com') do |r|
148
+ # r.text /Post: (\d+)-(\h+)/ do |post_id, hmac|
149
+ # next unless Post[post_id.to_i]
150
+ # unhandled_mail("no matching Post") unless post = Post[post_id.to_i]
151
+ # unhandled_mail("HMAC for doesn't match for post") unless hmac == post.hmac_for_address(from.first)
152
+ #
153
+ # r.handle_text 'APPROVE' do
154
+ # post.approved_by(from)
155
+ # end
156
+ #
157
+ # r.handle_text 'DENY' do
158
+ # post.denied_by(from)
159
+ # end
160
+ # end
161
+ # end
162
+ #
163
+ # The +rcpt+ class method does not mark the messages as handled, because in most cases you will
164
+ # need to do additional matching to extract the information necessary to handle
165
+ # the mail. You will need to call +r.handle+ or similar method inside the block
166
+ # to mark the mail as handled.
167
+ #
168
+ # Matching on strings provided to the +rcpt+ class method is an O(1) operation as
169
+ # the strings are stored lowercase in a hash. Matching on regexps provided to the
170
+ # +rcpt+ class method is an O(n) operation on the number of regexps.
171
+ #
172
+ # If you would like to break up the routing tree using something other than the
173
+ # recipient address, you can use the multi_route plugin.
174
+ #
175
+ # = Hooks
176
+ #
177
+ # The mail_processor plugin offers hooks for processing mail.
178
+ #
179
+ # For mail that is handled successfully, you can use the handled_mail hook:
180
+ #
181
+ # MailProcessor.handled_mail do
182
+ # # nothing by default
183
+ # end
184
+ #
185
+ # For mail that is not handled successfully, either because +r.handle+ was not called
186
+ # during routing or because the +unhandled_mail+ method was called explicitly,
187
+ # you can use the unhandled_mail hook.
188
+ #
189
+ # The default is to reraise the UnhandledMail exception that was raised during routing,
190
+ # so that calling code will not be able to ignore errors when processing mail. However,
191
+ # you may want to save such mails to a special location or forward them as attachments
192
+ # for manual review, and the unhandled_mail hook allows you to do that:
193
+ #
194
+ # MailProcessor.unhandled_mail do
195
+ # # raise by default
196
+ #
197
+ # # Forward the mail as an attachment to an admin
198
+ # m = Mail.new
199
+ # m.to 'admin@example.com'
200
+ # m.subject '[APP] Unhandled Received Email'
201
+ # m.add_file(filename: 'message.eml', :content=>mail.encoded)
202
+ # m.deliver
203
+ # end
204
+ #
205
+ # Finally, for all processed mail, regardless of whether it was handled or not,
206
+ # there is an after_mail hook, which can be used to archive all processed mail:
207
+ #
208
+ # MailProcessor.after_mail do
209
+ # # nothing by default
210
+ #
211
+ # # Add it to a received_mail table using Sequel
212
+ # DB[:received_mail].insert(:message=>mail.encoded)
213
+ # end
214
+ #
215
+ # The after_mail hook is called after the handled_mail or unhandled_mail hook
216
+ # is called, even if routing, the handled_mail hook, or the unhandled_mail hook
217
+ # raises an exception. The handled_mail and unhandled_mail hooks are not called
218
+ # if an exception is raised during routing (other than for UnhandledMail exceptions).
219
+ #
220
+ # = Extracting Text from Mail
221
+ #
222
+ # The most common use of the mail_processor plugin is to handle replies to mails sent
223
+ # out by the application, so that recipients can reply to mail to make changes without
224
+ # having to access the application directly. When handling replies, it is common to want
225
+ # to extract only the text of the reply, and ignore the text of the message that was
226
+ # replied to. Because there is no consistent way to format replies in mail, there have
227
+ # evolved various approaches to do this, with some gems devoted to extracting the reply
228
+ # text from a message.
229
+ #
230
+ # The mail_processor plugin does not choose any particular approach for extracting text from mail,
231
+ # but it includes the ability to configure how to do that via the +mail_text+ class method.
232
+ # This method affects the +r.text+ match method, as well as +mail_text+ instance method.
233
+ # By default, the decoded body of the mail is used as the mail text.
234
+ #
235
+ # MailProcessor.mail_text do
236
+ # # mail.body.decoded by default
237
+ #
238
+ # # https://github.com/github/email_reply_parser
239
+ # EmailReplyParser.parse_reply(mail.body.decoded)
240
+ #
241
+ # # https://github.com/fiedl/extended_email_reply_parser
242
+ # mail.parse
243
+ # end
244
+ #
245
+ # = Security
246
+ #
247
+ # Note that due to the way mail delivery works via SMTP, the actual sender and recipient of
248
+ # the mail (the SMTP envelope MAIL FROM and RCPT TO addresses) may not match the sender and
249
+ # receiver embedded in the message. Because mail_processor routing relies on parsing the mail,
250
+ # it does not have access to the actual sender and recipient used at the SMTP level, unless
251
+ # a mail server adds that information as a header to the mail (and clears any existing header
252
+ # to prevent spoofing). Keep that in mind when you are setting up your mail routes. If you
253
+ # have setup your mail server to add the SMTP RCPT TO information to a header, you may want
254
+ # to only consider that header when looking for the recipients of the message, instead of
255
+ # looking at the To and CC headers. You can override the default behavior for determining
256
+ # the recipients (this will affect the +rcpt+ class method, +r.rcpt+ match method, and
257
+ # +mail_recipients+ instance method):
258
+ #
259
+ # MailProcessor.mail_recipients do
260
+ # # Assuming the information is in the X-SMTP-To header
261
+ # Array(header['X-SMTP-To'].decoded)
262
+ # end
263
+ #
264
+ # Also note that unlike when handling web requests where you can rely on storing authentication
265
+ # information in the session, when processing mail, you should manually authenticate each message,
266
+ # as email is trivially forged. One way to do this is assigning and storing a unique identifier when
267
+ # sending each message, and checking for a matching identifier when receiving a response. Another
268
+ # option is including a computable authentication code (e.g. HMAC) in the message, and then
269
+ # when receiving a response, recomputing the authentication code and seeing if it matches the
270
+ # authentication code in the message. The unique identifier approach requires storing a large
271
+ # number of identifiers, but allows you to remove the identifier after a reply is received
272
+ # (to ensure only one response is handled). The authentication code approach does not
273
+ # require additional storage, but does not allow you to ensure only a single response is handled.
274
+ #
275
+ # = Avoiding Mail Loops
276
+ #
277
+ # If processing the mail results in sending out additional mail, be careful not to send a
278
+ # response to the sender of the email, otherwise if the sender of the email has an
279
+ # auto-responder, you can end up with a mail loop, where every mail you send results in
280
+ # a response, which you then process and send out a response to.
281
+ module MailProcessor
282
+ # Exception class raised when a mail processed is not handled during routing,
283
+ # either implicitly because the +r.handle+ method was not called, or via an explicit
284
+ # call to +unhandled_mail+.
285
+ class UnhandledMail < StandardError; end
286
+
287
+ module ClassMethods
288
+ # Freeze the rcpt routes if they are present.
289
+ def freeze
290
+ if string_routes = opts[:mail_processor_string_routes].freeze
291
+ string_routes.freeze
292
+ opts[:mail_processor_regexp_routes].freeze
293
+ end
294
+ super
295
+ end
296
+
297
+ # Process the given Mail instance, calling the appropriate hooks depending on
298
+ # whether the mail was handled during processing.
299
+ def process_mail(mail)
300
+ scope = new("PATH_INFO"=>'', 'SCRIPT_NAME'=>'', "REQUEST_METHOD"=>"PROCESSMAIL", 'rack.input'=>StringIO.new, 'roda.mail'=>mail)
301
+
302
+ begin
303
+ begin
304
+ scope.process_mail(&route_block)
305
+ rescue UnhandledMail
306
+ scope.unhandled_mail_hook
307
+ else
308
+ scope.handled_mail_hook
309
+ end
310
+ ensure
311
+ scope.after_mail_hook
312
+ end
313
+ end
314
+
315
+ # Process all mail in the given mailbox. If the +:retriever+ option is
316
+ # given, should be an object supporting the Mail retriever API, otherwise
317
+ # uses the default Mail retriever_method. This deletes retrieved mail from the
318
+ # mailbox after processing, so that when called multiple times it does
319
+ # not reprocess the same mail. If mail should be archived and not deleted,
320
+ # the +after_mail+ method should be used to perform the archiving of the mail.
321
+ def process_mailbox(opts=OPTS)
322
+ (opts[:retriever] || Mail).find_and_delete(opts.dup){|m| process_mail(m)}
323
+ nil
324
+ end
325
+
326
+ # Setup a routing tree for the given recipient addresses, which can be strings or regexps.
327
+ # Any messages matching the given recipient address will use these routing trees instead
328
+ # of the normal routing tree.
329
+ def rcpt(*addresses, &block)
330
+ opts[:mail_processor_string_routes] ||= {}
331
+ opts[:mail_processor_regexp_routes] ||= {}
332
+ addresses.each do |address|
333
+ key = case address
334
+ when String
335
+ :mail_processor_string_routes
336
+ when Regexp
337
+ :mail_processor_regexp_routes
338
+ else
339
+ raise RodaError, "invalid address format passed to rcpt, should be Array or String"
340
+ end
341
+ opts[key][address] = block
342
+ end
343
+ nil
344
+ end
345
+
346
+ %w'after_mail handled_mail unhandled_mail'.each do |meth|
347
+ class_eval(<<-END, __FILE__, __LINE__+1)
348
+ def #{meth}(&block)
349
+ define_method(:#{meth}_hook, &block)
350
+ nil
351
+ end
352
+ END
353
+ end
354
+
355
+ %w'mail_recipients mail_text'.each do |meth|
356
+ class_eval(<<-END, __FILE__, __LINE__+1)
357
+ def #{meth}(&block)
358
+ define_method(:#{meth}, &block)
359
+ nil
360
+ end
361
+ END
362
+ end
363
+ end
364
+
365
+ module InstanceMethods
366
+ [:to, :from, :cc, :body, :subject, :header].each do |field|
367
+ class_eval(<<-END, __FILE__, __LINE__+1)
368
+ def #{field}
369
+ mail.#{field}
370
+ end
371
+ END
372
+ end
373
+
374
+ # Perform the processing of mail for this request, first considering
375
+ # routes defined via the the class-level +rcpt+ method, and then the
376
+ # normal routing tree passed in as the block.
377
+ def process_mail(&block)
378
+ if string_routes = opts[:mail_processor_string_routes]
379
+ addresses = mail_recipients
380
+
381
+ addresses.each do |address|
382
+ if blk = string_routes[address.to_s.downcase]
383
+ call(&blk)
384
+ return
385
+ end
386
+ end
387
+
388
+ opts[:mail_processor_regexp_routes].each do |regexp, blk|
389
+ addresses.each do |address|
390
+ if md = regexp.match(address)
391
+ call do |r|
392
+ instance_exec(r, *md.captures, &blk)
393
+ end
394
+ return
395
+ end
396
+ end
397
+ end
398
+ end
399
+
400
+ call(&block)
401
+
402
+ nil
403
+ end
404
+
405
+ # Hook called after processing any mail, whether the mail was
406
+ # handled or not. Does nothing by default.
407
+ def after_mail_hook
408
+ nil
409
+ end
410
+
411
+ # Hook called after processing a mail, when the mail was handled.
412
+ # Does nothing by default.
413
+ def handled_mail_hook
414
+ nil
415
+ end
416
+
417
+ # Hook called after processing a mail, when the mail was not handled.
418
+ # Reraises the UnhandledMail exception raised during mail processing
419
+ # by default.
420
+ def unhandled_mail_hook
421
+ raise
422
+ end
423
+
424
+ # The mail instance being processed.
425
+ def mail
426
+ env['roda.mail']
427
+ end
428
+
429
+ # The text of the mail instance being processed, uses the
430
+ # decoded body of the mail by default.
431
+ def mail_text
432
+ mail.body.decoded
433
+ end
434
+
435
+ # The recipients of the mail instance being processed, uses the To and CC
436
+ # headers by default.
437
+ def mail_recipients
438
+ Array(to) + Array(cc)
439
+ end
440
+
441
+ # Raise an UnhandledMail exception with the given reason, used to mark the
442
+ # mail as not handled. A reason why the mail was not handled must be
443
+ # provided, which will be used as the exception message.
444
+ def unhandled_mail(reason)
445
+ raise UnhandledMail, reason
446
+ end
447
+ end
448
+
449
+ module RequestMethods
450
+ [:to, :from, :cc, :body, :subject, :rcpt, :text].each do |field|
451
+ class_eval(<<-END, __FILE__, __LINE__+1)
452
+ def handle_#{field}(val)
453
+ #{field}(val) do |*args|
454
+ handle do
455
+ yield(*args)
456
+ end
457
+ end
458
+ end
459
+
460
+ def #{field}(address, &block)
461
+ on(:#{field}=>address, &block)
462
+ end
463
+
464
+ private
465
+
466
+ def match_#{field}(address)
467
+ _match_address(:#{field}, address, Array(mail.#{field}))
468
+ end
469
+ END
470
+ end
471
+
472
+ undef_method :match_rcpt
473
+ undef_method :match_text
474
+
475
+ # Same as +header+, but also mark the message as being handled.
476
+ def handle_header(key, value=nil)
477
+ header(key, value) do |*args|
478
+ handle do
479
+ yield(*args)
480
+ end
481
+ end
482
+ end
483
+
484
+ # Match based on a mail header value.
485
+ def header(key, value=nil, &block)
486
+ on(:header=>[key, value], &block)
487
+ end
488
+
489
+ # Mark the mail as having been handled, so routing will not call
490
+ # unhandled_mail implicitly.
491
+ def handle(&block)
492
+ env['roda.mail_handled'] = true
493
+ always(&block)
494
+ end
495
+
496
+ private
497
+
498
+ if RUBY_VERSION >= '2.4.0'
499
+ # Whether the addresses are the same (case insensitive match).
500
+ def address_match?(a1, a2)
501
+ a1.casecmp?(a2)
502
+ end
503
+ else
504
+ # :nocov:
505
+ def address_match?(a1, a2)
506
+ a1.downcase == a2.downcase
507
+ end
508
+ # :nocov:
509
+ end
510
+
511
+ # Match if any of the given addresses match the given val, which
512
+ # can be a string (case insensitive match of the string), array of
513
+ # strings (case insensitive match of any string), or regexp
514
+ # (normal regexp match).
515
+ def _match_address(field, val, addresses)
516
+ case val
517
+ when String
518
+ addresses.any?{|a| address_match?(a, val)}
519
+ when Array
520
+ overlap = []
521
+ addresses.each do |a|
522
+ val.each do |v|
523
+ if address_match?(a, v)
524
+ overlap << a
525
+ end
526
+ end
527
+ end
528
+
529
+ unless overlap.empty?
530
+ @captures.concat(overlap)
531
+ end
532
+ when Regexp
533
+ matched = false
534
+ addresses.each do |v|
535
+ if md = val.match(v)
536
+ matched = true
537
+ @captures.concat(md.captures)
538
+ end
539
+ end
540
+ matched
541
+ else
542
+ unsupported_matcher(:field=>val)
543
+ end
544
+ end
545
+
546
+ # Match if the content matches the given val, which
547
+ # can be a string (case sensitive substring match), array of
548
+ # strings (case sensitive substring match of any string), or regexp
549
+ # (normal regexp match).
550
+ def _match_content(field, val, content)
551
+ case val
552
+ when String
553
+ content.include?(val)
554
+ when Array
555
+ val.each do |v|
556
+ if content.include?(v)
557
+ return @captures << v
558
+ end
559
+ end
560
+ false
561
+ when Regexp
562
+ if md = content.match(val)
563
+ @captures.concat(md.captures)
564
+ end
565
+ else
566
+ unsupported_matcher(field=>val)
567
+ end
568
+ end
569
+
570
+ # Match the value against the full mail body.
571
+ def match_body(val)
572
+ _match_content(:body, val, mail.body.decoded)
573
+ end
574
+
575
+ # Match the value against the mail subject.
576
+ def match_subject(val)
577
+ _match_content(:subject, val, mail.subject)
578
+ end
579
+
580
+ # Match the given address against all recipients in the mail.
581
+ def match_rcpt(address)
582
+ _match_address(:rcpt, address, scope.mail_recipients)
583
+ end
584
+
585
+ # Match the value against the extracted mail text.
586
+ def match_text(val)
587
+ _match_content(:text, val, scope.mail_text)
588
+ end
589
+
590
+ # Match against a header specified by key with the given
591
+ # value (which may be nil).
592
+ def match_header((key, value))
593
+ return unless content = mail.header[key]
594
+
595
+ if value.nil?
596
+ @captures << content.decoded
597
+ else
598
+ _match_content(:header, value, content.decoded)
599
+ end
600
+ end
601
+
602
+ # The mail instance being processed.
603
+ def mail
604
+ env['roda.mail']
605
+ end
606
+
607
+ # If the routing did not explicitly mark the mail as handled
608
+ # mark it as unhandled.
609
+ def block_result_body(_)
610
+ unless env['roda.mail_handled']
611
+ scope.unhandled_mail('mail was not handled during mail_processor routing')
612
+ end
613
+ end
614
+ end
615
+ end
616
+
617
+ register_plugin(:mail_processor, MailProcessor)
618
+ end
619
+ end
620
+
data/lib/roda/version.rb CHANGED
@@ -4,7 +4,7 @@ class Roda
4
4
  RodaMajorVersion = 3
5
5
 
6
6
  # The minor version of Roda, updated for new feature releases of Roda.
7
- RodaMinorVersion = 15
7
+ RodaMinorVersion = 16
8
8
 
9
9
  # The patch version of Roda, updated only for bug fixes from the last
10
10
  # feature release.
@@ -0,0 +1,402 @@
1
+ require_relative "../spec_helper"
2
+
3
+ begin
4
+ require 'mail'
5
+ rescue LoadError
6
+ warn "mail not installed, skipping mail_processor plugin test"
7
+ else
8
+ Mail.defaults do
9
+ retriever_method :test
10
+ end
11
+
12
+ describe "mail_processor plugin" do
13
+ def new_mail
14
+ m = Mail.new(:to=>'a@example.com', :from=>'b@example.com', :cc=>'c@example.com', :bcc=>'d@example.com', :subject=>'Sub', :body=>'Bod')
15
+ yield m if block_given?
16
+ m
17
+ end
18
+
19
+ def check
20
+ @processed.clear
21
+ yield
22
+ @processed
23
+ end
24
+
25
+ it "supports processing Mail instances via the routing tree using case insensitive address matchers" do
26
+ @processed = processed = []
27
+ app(:mail_processor) do |r|
28
+ r.to('a@example.com') do
29
+ r.handle_from(/@example.com/) do
30
+ processed << :to_a1
31
+ end
32
+ r.handle_from(/\A(.+)@example(\d).com\z/i) do |pre, id|
33
+ processed << :to_a2 << pre << id
34
+ end
35
+ r.handle_cc(['d@example.com', 'c@example.com']) do |ad|
36
+ processed << :to_a3 << ad
37
+ end
38
+ r.handle do
39
+ processed << :to_a4
40
+ end
41
+ end
42
+ r.handle_to('e@example.com') do
43
+ processed << :to_e
44
+ end
45
+ r.handle_rcpt('f@example.com') do
46
+ processed << :to_f
47
+ end
48
+ end
49
+
50
+ check{app.process_mail(new_mail)}.must_equal [:to_a1]
51
+ check{app.process_mail(new_mail{|m| m.from 'b2@example2.com'})}.must_equal [:to_a2, "b2", "2"]
52
+ check{app.process_mail(new_mail{|m| m.from 'b2@example12.com'})}.must_equal [:to_a3, 'c@example.com']
53
+ check{app.process_mail(new_mail{|m| m.from 'b2@f.com'; m.cc []})}.must_equal [:to_a4]
54
+ check{app.process_mail(new_mail{|m| m.to 'e@example.com'})}.must_equal [:to_e]
55
+ check{app.process_mail(new_mail{|m| m.to 'f@example.com'})}.must_equal [:to_f]
56
+ check{app.process_mail(new_mail{|m| m.to 'foo@example.com'; m.cc 'f@example.com'})}.must_equal [:to_f]
57
+
58
+ app.freeze
59
+
60
+ check{app.process_mail(new_mail{|m| m.to 'A@example.com'})}.must_equal [:to_a1]
61
+ check{app.process_mail(new_mail{|m| m.from 'b2@Example2.com'})}.must_equal [:to_a2, "b2", "2"]
62
+ check{app.process_mail(new_mail{|m| m.from 'b2@EXAMPLE12.com'})}.must_equal [:to_a3, 'c@example.com']
63
+ check{app.process_mail(new_mail{|m| m.from 'b2@f.COM'; m.cc []})}.must_equal [:to_a4]
64
+ check{app.process_mail(new_mail{|m| m.to 'E@EXAmple.com'})}.must_equal [:to_e]
65
+ check{app.process_mail(new_mail{|m| m.to 'f@exAMPLe.com'})}.must_equal [:to_f]
66
+ check{app.process_mail(new_mail{|m| m.to 'FOo@eXAMPle.com'; m.cc 'f@eXAMPle.com'})}.must_equal [:to_f]
67
+ end
68
+
69
+ it "supports processing Mail instances via the routing tree using body and subject matchers" do
70
+ @processed = processed = []
71
+ app(:mail_processor) do |r|
72
+ r.handle_subject('Sub') do
73
+ r.handle_body(/XID: (\d+)/) do |xid|
74
+ processed << :sb << xid
75
+ end
76
+ processed << :s1
77
+ end
78
+ r.handle_subject(['Su', 'Si']) do |sub|
79
+ processed << :s2 << sub
80
+ end
81
+ r.subject(/S([ao])/) do |sub|
82
+ r.handle do
83
+ processed << :s3 << sub
84
+ end
85
+ end
86
+ end
87
+
88
+ check{app.process_mail(new_mail)}.must_equal [:s1]
89
+ check{app.process_mail(new_mail{|m| m.subject 'Si'})}.must_equal [:s2, 'Si']
90
+ check{app.process_mail(new_mail{|m| m.subject 'Sa'})}.must_equal [:s3, 'a']
91
+ check{app.process_mail(new_mail{|m| m.body 'XID: 1234'})}.must_equal [:sb, '1234']
92
+ end
93
+
94
+ it "supports processing Mail instances via the routing tree using header matchers" do
95
+ @processed = processed = []
96
+ app(:mail_processor) do |r|
97
+ r.handle_header('X-Test') do |v|
98
+ processed << :x1 << v
99
+ end
100
+ r.handle_header('X-Test2', 'Foo') do
101
+ processed << :x2
102
+ end
103
+ r.handle_header('X-Test2', ['Foo', 'Bar']) do |val|
104
+ processed << :x3 << val
105
+ end
106
+ r.header('X-Test2', /(\d+)/) do |i|
107
+ r.handle do
108
+ processed << :x4 << i
109
+ end
110
+ end
111
+ r.handle do
112
+ processed << :f
113
+ end
114
+ end
115
+
116
+ check{app.process_mail(new_mail)}.must_equal [:f]
117
+ check{app.process_mail(new_mail{|m| m.header['X-Test'] = 'Foo'})}.must_equal [:x1, 'Foo']
118
+ check{app.process_mail(new_mail{|m| m.header['X-Test2'] = 'Foo'})}.must_equal [:x2]
119
+ check{app.process_mail(new_mail{|m| m.header['X-Test2'] = 'Bar'})}.must_equal [:x3, 'Bar']
120
+ check{app.process_mail(new_mail{|m| m.header['X-Test2'] = 'foo 3'})}.must_equal [:x4, '3']
121
+ end
122
+
123
+ it "calls unhandled_mail block for email not handled by a routing block" do
124
+ @processed = processed = []
125
+ app(:mail_processor) do |r|
126
+ r.to('a@example.com') do
127
+ processed << :on_to
128
+ end
129
+ processed << :miss
130
+ end
131
+ app.unhandled_mail do
132
+ processed << :uh << mail.to.first
133
+ end
134
+ check{app.process_mail(new_mail)}.must_equal [:on_to, :uh, 'a@example.com']
135
+ check{app.process_mail(new_mail{|m| m.to 'b@example.com'})}.must_equal [:miss, :uh, 'b@example.com']
136
+ end
137
+
138
+ it "calls handled_mail block for email handled by a routing block" do
139
+ @processed = processed = []
140
+ app(:mail_processor) do |r|
141
+ r.handle_to('a@example.com') do
142
+ processed << :to
143
+ end
144
+ end
145
+ app.handled_mail do
146
+ processed << :h << mail.to.first
147
+ end
148
+ check{app.process_mail(new_mail)}.must_equal [:to, :h, 'a@example.com']
149
+ end
150
+
151
+ it "raises by default for unhandled email" do
152
+ @processed = processed = []
153
+ app(:mail_processor) do |r|
154
+ processed << :miss
155
+ end
156
+ proc{app.process_mail(new_mail)}.must_raise Roda::RodaPlugins::MailProcessor::UnhandledMail
157
+ processed.must_equal [:miss]
158
+ end
159
+
160
+ it "allows calling unhandled_mail directly, and not calling either implicitly if called directly" do
161
+ @processed = processed = []
162
+ app(:mail_processor) do |r|
163
+ r.handle_to('a@example.com') do
164
+ r.handle_from('b@example.com') do
165
+ processed << :quux
166
+ end
167
+ unhandled_mail "bar"
168
+ processed << :foo
169
+ end
170
+ r.to('d@example.com') do
171
+ r.handle do
172
+ processed << :bar
173
+ unhandled_mail "foo"
174
+ end
175
+ end
176
+ r.handle do
177
+ processed << :baz
178
+ end
179
+ end
180
+ app.unhandled_mail do
181
+ processed << :uh
182
+ end
183
+ app.handled_mail do
184
+ processed << :h
185
+ end
186
+ app.after_mail do
187
+ processed << :a
188
+ end
189
+ check{app.process_mail(new_mail)}.must_equal [:quux, :h, :a]
190
+ check{app.process_mail(new_mail{|m| m.from 'c@example.com'})}.must_equal [:uh, :a]
191
+ check{app.process_mail(new_mail{|m| m.to 'd@example.com'})}.must_equal [ :bar, :uh, :a]
192
+ check{app.process_mail(new_mail{|m| m.to 'e@example.com'})}.must_equal [ :baz, :h, :a]
193
+ end
194
+
195
+ it "always calls after_mail after processing an email, even if the mail is not handled" do
196
+ @processed = processed = []
197
+ app(:mail_processor) do |r|
198
+ r.handle_to('a@example.com') do
199
+ processed << :t
200
+ end
201
+ r.handle_to('b@example.com') do
202
+ raise
203
+ end
204
+ end
205
+ app.unhandled_mail do
206
+ processed << :uh
207
+ end
208
+ app.after_mail do
209
+ processed << :a
210
+ end
211
+ check{app.process_mail(new_mail)}.must_equal [:t, :a]
212
+ check{app.process_mail(new_mail{|m| m.to 'd@example.com'})}.must_equal [:uh, :a]
213
+ check{proc{app.process_mail(new_mail{|m| m.to 'b@example.com'})}.must_raise RuntimeError}.must_equal [:a]
214
+ end
215
+
216
+ it "always calls after_mail after processing an email, even if handled_mail or unhandled_mail hooks raise an exception" do
217
+ @processed = processed = []
218
+ app(:mail_processor) do |r|
219
+ r.handle_to('a@example.com') do
220
+ processed << :t
221
+ end
222
+ end
223
+ app.handled_mail do
224
+ processed << :h
225
+ raise "foo"
226
+ end
227
+ app.unhandled_mail do
228
+ processed << :uh
229
+ raise "foo"
230
+ end
231
+ app.after_mail do
232
+ processed << :a
233
+ end
234
+ check{proc{app.process_mail(new_mail)}.must_raise RuntimeError}.must_equal [:t, :h, :a]
235
+ check{proc{app.process_mail(new_mail{|m| m.to 'd@example.com'})}.must_raise RuntimeError}.must_equal [:uh, :a]
236
+ end
237
+
238
+ it "should raise RodaError for unsupported address and content matchers" do
239
+ app(:mail_processor) do |r|
240
+ r.subject('Sub') do
241
+ r.subject(Object.new) do
242
+ end
243
+ end
244
+ r.subject('Si') do
245
+ r.from(Object.new) do
246
+ end
247
+ end
248
+ end
249
+
250
+ proc{app.process_mail(new_mail)}.must_raise Roda::RodaError
251
+ proc{app.process_mail(new_mail{|m| m.subject 'Si'})}.must_raise Roda::RodaError
252
+ end
253
+
254
+ it "supports processing retrieved mail from a mailbox via the routing tree" do
255
+ @processed = processed = []
256
+ app(:mail_processor) do |r|
257
+ r.handle_to('a@example.com') do
258
+ processed << :to_a
259
+ end
260
+ r.handle_to('c@example.com') do
261
+ processed.concat(mail.to)
262
+ end
263
+ end
264
+ Mail::TestRetriever.emails = [new_mail]
265
+ check{app.process_mailbox}.must_equal [:to_a]
266
+ Mail::TestRetriever.emails = [new_mail{|m| m.to 'c@example.com'}]
267
+ check{app.process_mailbox}.must_equal ['c@example.com']
268
+ Mail::TestRetriever.emails = [new_mail] * 10
269
+ check{app.process_mailbox}.must_equal([:to_a]*10)
270
+ Mail::TestRetriever.emails = Array.new(10){new_mail}
271
+ check{app.process_mailbox(:count=>2)}.must_equal([:to_a]*2)
272
+ check{app.process_mailbox}.must_equal([:to_a]*8)
273
+ end
274
+
275
+ it "supports processing retrieved mail from a mailbox with a custom :retreiver" do
276
+ @processed = processed = []
277
+ emails = []
278
+ retriever = Class.new(Mail::Retriever) do
279
+ define_method(:find) do |opts={}, &block|
280
+ es = emails.dup
281
+ emails.clear
282
+ es.each(&block) if block
283
+ es
284
+ end
285
+ end.new
286
+ app(:mail_processor) do |r|
287
+ r.handle_to('a@example.com') do
288
+ processed << :to_a
289
+ end
290
+ end
291
+ emails << new_mail
292
+ check{app.process_mailbox}.must_equal []
293
+ emails.wont_be_empty
294
+ check{app.process_mailbox(:retriever=>retriever)}.must_equal [:to_a]
295
+ emails.must_be_empty
296
+ check{app.process_mailbox(:retriever=>retriever)}.must_equal []
297
+ end
298
+
299
+ it "supports rcpt class method to delegate to blocks by recipient address, falling back to main routing block" do
300
+ @processed = processed = []
301
+ app(:mail_processor) do |r|
302
+ r.handle do
303
+ processed << :f
304
+ end
305
+ end
306
+ app.rcpt('a@example.com') do |r|
307
+ r.handle do
308
+ processed << :a
309
+ end
310
+ end
311
+ app.rcpt(/(.*[bcd])@example.com/i) do |r, x|
312
+ r.handle do
313
+ processed << :bcd << x
314
+ end
315
+ end
316
+ app.rcpt(/([cde])@example.com(.*)/i) do |r, x, y|
317
+ r.handle do
318
+ processed << :cde << x << y
319
+ end
320
+ end
321
+ app.rcpt('B@EXAMPLE.com', 'c@example.com') do |r|
322
+ r.handle do
323
+ processed << :bc
324
+ end
325
+ end
326
+ app.rcpt('x@example.com') do |r|
327
+ processed << :x
328
+ end
329
+ proc{app.rcpt(Object.new){}}.must_raise Roda::RodaError
330
+
331
+ check{app.process_mail(new_mail)}.must_equal [:a]
332
+
333
+ app.freeze
334
+
335
+ check{app.process_mail(new_mail{|m| m.to 'b@example.com'})}.must_equal [:bc]
336
+ check{app.process_mail(new_mail{|m| m.to 'C@example.com'; m.cc 'a@example.com'})}.must_equal [:bc]
337
+ check{app.process_mail(new_mail{|m| m.to 'd@example.com'; m.cc 'a@example.com'})}.must_equal [:a]
338
+ check{app.process_mail(new_mail{|m| m.to 'd@example.com'; m.cc []})}.must_equal [:bcd, 'd']
339
+ check{app.process_mail(new_mail{|m| m.to '123d@example.com123'; m.cc []})}.must_equal [:bcd, '123d']
340
+ check{app.process_mail(new_mail{|m| m.to 'e@example.com'; m.cc []})}.must_equal [:cde, 'e', '']
341
+ check{app.process_mail(new_mail{|m| m.to '123e@example.com123'; m.cc []})}.must_equal [:cde, 'e', '123']
342
+ check{proc{app.process_mail(new_mail{|m| m.to 'x@example.com'})}.must_raise Roda::RodaPlugins::MailProcessor::UnhandledMail}.must_equal [:x]
343
+ end
344
+
345
+ it "supports mail_recipients class method to set recipients of mail, respected by rcpt methods" do
346
+ @processed = processed = []
347
+ app(:mail_processor) do |r|
348
+ r.handle_rcpt('a@example.com') do
349
+ processed << :a
350
+ end
351
+ r.handle do
352
+ processed << :f
353
+ end
354
+ end
355
+ app.rcpt('b@example.com') do |r|
356
+ r.handle do
357
+ processed << :b
358
+ end
359
+ end
360
+ check{app.process_mail(new_mail)}.must_equal [:a]
361
+ check{app.process_mail(new_mail{|m| m.to 'b@example.com'})}.must_equal [:b]
362
+ check{app.process_mail(new_mail{|m| m.to 'e@example.com'})}.must_equal [:f]
363
+
364
+ app.mail_recipients do
365
+ if smtp_rcpt = header['X-SMTP-To']
366
+ smtp_rcpt = smtp_rcpt.decoded
367
+ end
368
+ Array(smtp_rcpt)
369
+ end
370
+ check{app.process_mail(new_mail)}.must_equal [:f]
371
+ check{app.process_mail(new_mail{|m| m.header['X-SMTP-To'] = 'a@example.com'})}.must_equal [:a]
372
+ check{app.process_mail(new_mail{|m| m.header['X-SMTP-To'] = 'b@example.com'})}.must_equal [:b]
373
+ end
374
+
375
+ it "supports #mail_text, .mail_text, and r.text for allowing the ability to extract text from mails" do
376
+ @processed = processed = []
377
+ app(:mail_processor) do |r|
378
+ r.handle_text(/Found (foo|bar)/) do |x|
379
+ processed << :f << x
380
+ end
381
+ r.text(/Found (baz|quux)/) do |x|
382
+ r.handle do
383
+ processed << :f2 << x << mail_text
384
+ end
385
+ end
386
+ r.handle do
387
+ processed << :nf << mail_text
388
+ end
389
+ end
390
+ check{app.process_mail(new_mail)}.must_equal [:nf, 'Bod']
391
+ check{app.process_mail(new_mail{|m| m.body "Found bar\n--\nFound foo"})}.must_equal [:f, 'bar']
392
+ check{app.process_mail(new_mail{|m| m.body "> Found baz\nFound quux"})}.must_equal [:f2, 'baz', "> Found baz\nFound quux"]
393
+ @app.mail_text do
394
+ text = mail.body.decoded.gsub(/^>[^\r\n]*\r?\n/m, '')
395
+ text.split(/\r?\n--\r?\n/).last
396
+ end
397
+ check{app.process_mail(new_mail)}.must_equal [:nf, 'Bod']
398
+ check{app.process_mail(new_mail{|m| m.body "Found bar\n--\nFound foo"})}.must_equal [:f, 'foo']
399
+ check{app.process_mail(new_mail{|m| m.body "> Found baz\nFound quux"})}.must_equal [:f2, 'quux', "Found quux"]
400
+ end
401
+ end
402
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: roda
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.15.0
4
+ version: 3.16.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jeremy Evans
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2018-12-14 00:00:00.000000000 Z
11
+ date: 2019-01-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack
@@ -212,6 +212,7 @@ extra_rdoc_files:
212
212
  - doc/release_notes/3.14.0.txt
213
213
  - doc/release_notes/3.14.1.txt
214
214
  - doc/release_notes/3.15.0.txt
215
+ - doc/release_notes/3.16.0.txt
215
216
  files:
216
217
  - CHANGELOG
217
218
  - MIT-LICENSE
@@ -262,6 +263,7 @@ files:
262
263
  - doc/release_notes/3.14.0.txt
263
264
  - doc/release_notes/3.14.1.txt
264
265
  - doc/release_notes/3.15.0.txt
266
+ - doc/release_notes/3.16.0.txt
265
267
  - doc/release_notes/3.2.0.txt
266
268
  - doc/release_notes/3.3.0.txt
267
269
  - doc/release_notes/3.4.0.txt
@@ -313,6 +315,7 @@ files:
313
315
  - lib/roda/plugins/indifferent_params.rb
314
316
  - lib/roda/plugins/json.rb
315
317
  - lib/roda/plugins/json_parser.rb
318
+ - lib/roda/plugins/mail_processor.rb
316
319
  - lib/roda/plugins/mailer.rb
317
320
  - lib/roda/plugins/match_affix.rb
318
321
  - lib/roda/plugins/middleware.rb
@@ -415,6 +418,7 @@ files:
415
418
  - spec/plugin/indifferent_params_spec.rb
416
419
  - spec/plugin/json_parser_spec.rb
417
420
  - spec/plugin/json_spec.rb
421
+ - spec/plugin/mail_processor_spec.rb
418
422
  - spec/plugin/mailer_spec.rb
419
423
  - spec/plugin/match_affix_spec.rb
420
424
  - spec/plugin/middleware_spec.rb
@@ -516,8 +520,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
516
520
  - !ruby/object:Gem::Version
517
521
  version: '0'
518
522
  requirements: []
519
- rubyforge_project:
520
- rubygems_version: 2.7.6
523
+ rubygems_version: 3.0.1
521
524
  signing_key:
522
525
  specification_version: 4
523
526
  summary: Routing tree web toolkit