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 +4 -4
- data/CHANGELOG +4 -0
- data/doc/release_notes/3.16.0.txt +52 -0
- data/lib/roda/plugins/mail_processor.rb +620 -0
- data/lib/roda/version.rb +1 -1
- data/spec/plugin/mail_processor_spec.rb +402 -0
- metadata +7 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 64aa07cd8d8fff3df32bb1ddb87089eb0248db3b21d76eb9d851bcebe48276af
|
4
|
+
data.tar.gz: 8865bb77e86f9671c60cd67bfbceb5b38761a9a662719d28dae6f2768ad07827
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
@@ -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.
|
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:
|
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
|
-
|
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
|