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 +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
|