roda 3.15.0 → 3.16.0

Sign up to get free protection for your applications and to get access to all the features.
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