replyr 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/CHANGELOG.md +3 -0
- data/Gemfile +14 -0
- data/MIT-LICENSE +20 -0
- data/README.md +94 -0
- data/Rakefile +32 -0
- data/lib/email_reply_parser/LICENSE +22 -0
- data/lib/email_reply_parser/email_reply_parser.rb +475 -0
- data/lib/generators/replyr/install_generator.rb +15 -0
- data/lib/generators/replyr/templates/mailman_daemon +12 -0
- data/lib/generators/replyr/templates/mailman_server +50 -0
- data/lib/generators/replyr/templates/replyr.rb.erb +8 -0
- data/lib/replyr/config.rb +22 -0
- data/lib/replyr/engine.rb +14 -0
- data/lib/replyr/handle_reply.rb +36 -0
- data/lib/replyr/reply_address.rb +111 -0
- data/lib/replyr/reply_email.rb +50 -0
- data/lib/replyr/version.rb +3 -0
- data/lib/replyr.rb +26 -0
- data/lib/tasks/replyr_tasks.rake +4 -0
- data/test/dummy/README.rdoc +28 -0
- data/test/dummy/Rakefile +6 -0
- data/test/dummy/app/assets/javascripts/application.js +13 -0
- data/test/dummy/app/assets/stylesheets/application.css +13 -0
- data/test/dummy/app/controllers/application_controller.rb +5 -0
- data/test/dummy/app/helpers/application_helper.rb +2 -0
- data/test/dummy/app/models/comment.rb +7 -0
- data/test/dummy/app/models/user.rb +3 -0
- data/test/dummy/app/views/layouts/application.html.erb +14 -0
- data/test/dummy/bin/bundle +3 -0
- data/test/dummy/bin/rails +4 -0
- data/test/dummy/bin/rake +4 -0
- data/test/dummy/config/application.rb +23 -0
- data/test/dummy/config/boot.rb +5 -0
- data/test/dummy/config/database.yml +8 -0
- data/test/dummy/config/environment.rb +5 -0
- data/test/dummy/config/environments/development.rb +29 -0
- data/test/dummy/config/environments/production.rb +80 -0
- data/test/dummy/config/environments/test.rb +36 -0
- data/test/dummy/config/initializers/backtrace_silencers.rb +7 -0
- data/test/dummy/config/initializers/filter_parameter_logging.rb +4 -0
- data/test/dummy/config/initializers/inflections.rb +16 -0
- data/test/dummy/config/initializers/mime_types.rb +5 -0
- data/test/dummy/config/initializers/replyr.rb +2 -0
- data/test/dummy/config/initializers/secret_token.rb +12 -0
- data/test/dummy/config/initializers/session_store.rb +3 -0
- data/test/dummy/config/initializers/wrap_parameters.rb +14 -0
- data/test/dummy/config/locales/en.yml +23 -0
- data/test/dummy/config/routes.rb +56 -0
- data/test/dummy/config.ru +4 -0
- data/test/dummy/db/test.sqlite3 +0 -0
- data/test/dummy/log/development.log +0 -0
- data/test/dummy/log/test.log +12557 -0
- data/test/dummy/public/404.html +58 -0
- data/test/dummy/public/422.html +58 -0
- data/test/dummy/public/500.html +57 -0
- data/test/dummy/public/favicon.ico +0 -0
- data/test/email_reply_parser/email_reply_parser_test.rb +488 -0
- data/test/email_reply_parser/emails/correct_sig.txt +4 -0
- data/test/email_reply_parser/emails/email_1_1.txt +13 -0
- data/test/email_reply_parser/emails/email_1_2.txt +51 -0
- data/test/email_reply_parser/emails/email_1_3.txt +55 -0
- data/test/email_reply_parser/emails/email_1_4.txt +5 -0
- data/test/email_reply_parser/emails/email_1_5.txt +15 -0
- data/test/email_reply_parser/emails/email_1_6.txt +15 -0
- data/test/email_reply_parser/emails/email_1_7.txt +12 -0
- data/test/email_reply_parser/emails/email_1_8.txt +6 -0
- data/test/email_reply_parser/emails/email_1_9.txt +9 -0
- data/test/email_reply_parser/emails/email_2_1.txt +25 -0
- data/test/email_reply_parser/emails/email_2_2.txt +10 -0
- data/test/email_reply_parser/emails/email_2_3.txt +14 -0
- data/test/email_reply_parser/emails/email_2_4.txt +14 -0
- data/test/email_reply_parser/emails/email_2_5.txt +15 -0
- data/test/email_reply_parser/emails/email_2_6.txt +11 -0
- data/test/email_reply_parser/emails/email_2_7.txt +5 -0
- data/test/email_reply_parser/emails/email_2_7_1.txt +5 -0
- data/test/email_reply_parser/emails/email_2_7_de.txt +5 -0
- data/test/email_reply_parser/emails/email_2_8.txt +3 -0
- data/test/email_reply_parser/emails/email_2_9.txt +9 -0
- data/test/email_reply_parser/emails/email_2nd_paragraph_starting_with_on.txt +12 -0
- data/test/email_reply_parser/emails/email_BlackBerry.txt +3 -0
- data/test/email_reply_parser/emails/email_bullets.txt +22 -0
- data/test/email_reply_parser/emails/email_from_address_in_quote_header.txt +12 -0
- data/test/email_reply_parser/emails/email_from_name_in_quote_header.txt +12 -0
- data/test/email_reply_parser/emails/email_hyphens.txt +5 -0
- data/test/email_reply_parser/emails/email_iPhone.txt +3 -0
- data/test/email_reply_parser/emails/email_iPhone_de.txt +3 -0
- data/test/email_reply_parser/emails/email_iPhone_de_2.txt +3 -0
- data/test/email_reply_parser/emails/email_mentions_own_email_address.txt +6 -0
- data/test/email_reply_parser/emails/email_mentions_own_name.txt +6 -0
- data/test/email_reply_parser/emails/email_multi_word_sent_from_my_mobile_device.txt +3 -0
- data/test/email_reply_parser/emails/email_multiline_quote_header_de_mx.txt +8 -0
- data/test/email_reply_parser/emails/email_multiline_quote_header_es_mx.txt +8 -0
- data/test/email_reply_parser/emails/email_multiline_quote_header_fr.txt +8 -0
- data/test/email_reply_parser/emails/email_multiline_quote_header_from_first.txt +11 -0
- data/test/email_reply_parser/emails/email_multiline_quote_header_from_replyto_date_to_subject.txt +12 -0
- data/test/email_reply_parser/emails/email_multiline_quote_header_from_to_date_subject.txt +11 -0
- data/test/email_reply_parser/emails/email_multiline_quote_header_none.txt +11 -0
- data/test/email_reply_parser/emails/email_multiline_quote_header_pt_br.txt +8 -0
- data/test/email_reply_parser/emails/email_multiline_quote_header_with_asterisks.txt +21 -0
- data/test/email_reply_parser/emails/email_multiline_quote_header_with_cc.txt +9 -0
- data/test/email_reply_parser/emails/email_multiline_quote_header_with_multiline_headers.txt +14 -0
- data/test/email_reply_parser/emails/email_no_signature_deliminator.txt +7 -0
- data/test/email_reply_parser/emails/email_no_signature_deliminator_adds_a_middle_initial.txt +7 -0
- data/test/email_reply_parser/emails/email_one_is_not_on.txt +10 -0
- data/test/email_reply_parser/emails/email_sent_from_my_not_signature.txt +3 -0
- data/test/email_reply_parser/emails/email_was_showing_as_nothing_visible.txt +13 -0
- data/test/email_reply_parser/emails/new_content/email_1_2.txt +28 -0
- data/test/replyr/handle_reply_test.rb +46 -0
- data/test/replyr/reply_address_test.rb +77 -0
- data/test/replyr/reply_email_test.rb +19 -0
- data/test/replyr_test.rb +7 -0
- data/test/support/emails/reply_multipart.eml +35 -0
- data/test/support/emails/reply_plain.eml +11 -0
- data/test/support/emails/reply_with_attachment.eml +466 -0
- data/test/test_helper.rb +23 -0
- metadata +228 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 05a40f3b1f0148dcff5d4643db55547ab76c7b1e
|
4
|
+
data.tar.gz: bad9440558e05ca2e926517dda94d662339e02a7
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 43442dddff869b36e478e8e415f2d680ca64a9d5a0e8f45ff2303ee4739684c66a2865e28069785964c901a1ea1596f09c5623259a30dacc5a3336dbf2e120b9
|
7
|
+
data.tar.gz: a62949cae32390d4639d08eb3836002ade91b9674555e4187f21cba87224d8350bd3cade18425b82c87ea9360592f999d5767ff137f4f9f5572a53261ba58892
|
data/CHANGELOG.md
ADDED
data/Gemfile
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
source "https://rubygems.org"
|
2
|
+
|
3
|
+
# Declare your gem's dependencies in replyr.gemspec.
|
4
|
+
# Bundler will treat runtime dependencies like base dependencies, and
|
5
|
+
# development dependencies will be added by default to the :development group.
|
6
|
+
gemspec
|
7
|
+
|
8
|
+
# Declare any dependencies that are still in development here instead of in
|
9
|
+
# your gemspec. These might include edge Rails or gems from your path or
|
10
|
+
# Git. Remember to move these dependencies to your gemspec before releasing
|
11
|
+
# your gem to rubygems.org.
|
12
|
+
|
13
|
+
# To use debugger
|
14
|
+
# gem 'debugger'
|
data/MIT-LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright 2014 Philipp Wüllner
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,94 @@
|
|
1
|
+
#Replyr [![Build Status](https://travis-ci.org/wursttheke/replyr.png?branch=master)](https://travis-ci.org/wursttheke/replyr)
|
2
|
+
|
3
|
+
Replyr lets you receive and process reply emails with Rails. And with ease!
|
4
|
+
|
5
|
+
#### Here's an example
|
6
|
+
|
7
|
+
> A User gets an email notification about a comment being posted on his page. Now he answers this email directly from his email client with a followup comment. Replyr receives the anwser and creates a new comment on his page.
|
8
|
+
|
9
|
+
#### How does this work?
|
10
|
+
|
11
|
+
Replyr generates a special `reply_to` email address which is unique to the user receiving the notification and the object in question (e.g. a comment). The resulting reply email address looks something like this:
|
12
|
+
|
13
|
+
reply-comment-12-56-01ce26dc69094af9246ea7e7ce9970aff2b81cc9@example.com
|
14
|
+
|
15
|
+
This address will be set as the reply_to address in the outgoing email from the rails app. When the user answers the email, the message will be sent to this address and Replyr will be able to handle it accordingly. It will check the mail for validity and then pass it to your custom code, which will then create a new comment.
|
16
|
+
|
17
|
+
#### How do the emails get into my app?
|
18
|
+
|
19
|
+
Replyr uses the awesome [Mailman Gem](https://github.com/titanous/mailman) to receive the reply emails. It can be configured to get the emails via Maildir, from a POP3- or from an IMAP-Server. It runs as a background daemon and observes your email directory or polls your email server for new emails. Take a look at the documentation to find out about its features and how to set it up:
|
20
|
+
|
21
|
+
https://github.com/titanous/mailman
|
22
|
+
|
23
|
+
## Requirements
|
24
|
+
|
25
|
+
- Ruby >= 1.9.3
|
26
|
+
- Rails 3 >= 3.1 or Rails 4.x
|
27
|
+
|
28
|
+
## Installtion
|
29
|
+
|
30
|
+
#### Add the gem to your `Gemfile`
|
31
|
+
|
32
|
+
```ruby
|
33
|
+
gem 'replyr'
|
34
|
+
```
|
35
|
+
|
36
|
+
#### Install
|
37
|
+
|
38
|
+
```bash
|
39
|
+
$ bundle
|
40
|
+
$ rails g replyr:install
|
41
|
+
```
|
42
|
+
|
43
|
+
#### Edit initializer
|
44
|
+
|
45
|
+
Open up `config/initializers/replyr.rb` and set the host name of your reply email address.
|
46
|
+
|
47
|
+
```ruby
|
48
|
+
Ryplr.config.host = "yourdomain.com"
|
49
|
+
```
|
50
|
+
|
51
|
+
#### Setup Mailman Gem
|
52
|
+
|
53
|
+
The Install Generator will already have created a `script/mailman_server` file which boots up the Mailman background job. According to your setup you will have to configure the file to your needs. By default it is setup to observe the `~/Maildir` folder in your home directory.
|
54
|
+
|
55
|
+
## Usage
|
56
|
+
|
57
|
+
#### Make a model accept replies
|
58
|
+
|
59
|
+
Update your ActiveRecord models you want to reply to by adding a `handle_reply` like this:
|
60
|
+
|
61
|
+
```ruby
|
62
|
+
class Comment < ActiveRecord::Base
|
63
|
+
belongs_to :user
|
64
|
+
|
65
|
+
handle_reply do |comment, user, text, files|
|
66
|
+
Comment.create(body: text)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
```
|
70
|
+
|
71
|
+
The `handle_reply` method takes a block with 4 parameters. The first one is the original object (in this case the comment). The second is the user who is sending the reply. The third is the reply plain text from the email (HTML mails are not supported). The fourth and last argument is an Array of `StringIO` objects representing all files attached to the emails.
|
72
|
+
|
73
|
+
What you put in the `handle_reply` block is completely up to you. In this example we are just creating a new comment, but you could also handle attached files by passing them to carrierwave, etc.
|
74
|
+
|
75
|
+
#### Make your mailers send the `reply_to` address
|
76
|
+
|
77
|
+
To add the unique reply address to your outgoing emails and make them 'replyable', add the reply_to option to your mailers methods:
|
78
|
+
|
79
|
+
```ruby
|
80
|
+
class CommentMailer < ActionMailer::Base
|
81
|
+
def new_comment(user, comment)
|
82
|
+
mail(to: user.email, reply_to: comment.reply_address_for_user(user))
|
83
|
+
end
|
84
|
+
end
|
85
|
+
```
|
86
|
+
|
87
|
+
## Start up
|
88
|
+
|
89
|
+
Start/Stop up the Mailman background worker with the following commands:
|
90
|
+
|
91
|
+
```bash
|
92
|
+
$ RAILS_ENV=production script/mailman_daemon start
|
93
|
+
$ RAILS_ENV=production script/mailman_daemon stop
|
94
|
+
```
|
data/Rakefile
ADDED
@@ -0,0 +1,32 @@
|
|
1
|
+
begin
|
2
|
+
require 'bundler/setup'
|
3
|
+
rescue LoadError
|
4
|
+
puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
|
5
|
+
end
|
6
|
+
|
7
|
+
require 'rdoc/task'
|
8
|
+
|
9
|
+
RDoc::Task.new(:rdoc) do |rdoc|
|
10
|
+
rdoc.rdoc_dir = 'rdoc'
|
11
|
+
rdoc.title = 'Replyr'
|
12
|
+
rdoc.options << '--line-numbers'
|
13
|
+
rdoc.rdoc_files.include('README.rdoc')
|
14
|
+
rdoc.rdoc_files.include('lib/**/*.rb')
|
15
|
+
end
|
16
|
+
|
17
|
+
|
18
|
+
|
19
|
+
|
20
|
+
Bundler::GemHelper.install_tasks
|
21
|
+
|
22
|
+
require 'rake/testtask'
|
23
|
+
|
24
|
+
Rake::TestTask.new(:test) do |t|
|
25
|
+
t.libs << 'lib'
|
26
|
+
t.libs << 'test'
|
27
|
+
t.pattern = 'test/**/*_test.rb'
|
28
|
+
t.verbose = false
|
29
|
+
end
|
30
|
+
|
31
|
+
|
32
|
+
task default: :test
|
@@ -0,0 +1,22 @@
|
|
1
|
+
The MIT License
|
2
|
+
|
3
|
+
Copyright (c) GitHub
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
22
|
+
|
@@ -0,0 +1,475 @@
|
|
1
|
+
# -*- encoding : utf-8 -*-
|
2
|
+
require 'strscan'
|
3
|
+
|
4
|
+
# EmailReplyParser is a small library to parse plain text email content. The
|
5
|
+
# goal is to identify which fragments are quoted, part of a signature, or
|
6
|
+
# original body content. We want to support both top and bottom posters, so
|
7
|
+
# no simple "REPLY ABOVE HERE" content is used.
|
8
|
+
#
|
9
|
+
# Beyond RFC 5322 (which is handled by the [Ruby mail gem][mail]), there aren't
|
10
|
+
# any real standards for how emails are created. This attempts to parse out
|
11
|
+
# common conventions for things like replies:
|
12
|
+
#
|
13
|
+
# this is some text
|
14
|
+
#
|
15
|
+
# On <date>, <author> wrote:
|
16
|
+
# > blah blah
|
17
|
+
# > blah blah
|
18
|
+
#
|
19
|
+
# ... and signatures:
|
20
|
+
#
|
21
|
+
# this is some text
|
22
|
+
#
|
23
|
+
# --
|
24
|
+
# Bob
|
25
|
+
# http://homepage.com/~bob
|
26
|
+
#
|
27
|
+
# Each of these are parsed into Fragment objects.
|
28
|
+
#
|
29
|
+
# EmailReplyParser also attempts to figure out which of these blocks should
|
30
|
+
# be hidden from users.
|
31
|
+
#
|
32
|
+
class EmailReplyParser
|
33
|
+
# Public: Splits an email body into a list of Fragments.
|
34
|
+
#
|
35
|
+
# text - A String email body.
|
36
|
+
# from_address - from address of the email (optional)
|
37
|
+
#
|
38
|
+
# Returns an Email instance.
|
39
|
+
def self.read(text, from_address = "")
|
40
|
+
Email.new.read(text, from_address)
|
41
|
+
end
|
42
|
+
|
43
|
+
# Public: Get the text of the visible portions of the given email body.
|
44
|
+
#
|
45
|
+
# text - A String email body.
|
46
|
+
# from_address - from address of the email (optional)
|
47
|
+
#
|
48
|
+
# Returns a String.
|
49
|
+
def self.parse_reply(text, from_address = "")
|
50
|
+
self.read(text.to_s, from_address).visible_text
|
51
|
+
end
|
52
|
+
|
53
|
+
def self.parse_new_content(text, from_address = "")
|
54
|
+
self.read(text, from_address).new_content
|
55
|
+
end
|
56
|
+
|
57
|
+
### Emails
|
58
|
+
|
59
|
+
# An Email instance represents a parsed body String.
|
60
|
+
class Email
|
61
|
+
# Emails have an Array of Fragments.
|
62
|
+
attr_reader :fragments
|
63
|
+
|
64
|
+
def initialize
|
65
|
+
@fragments = []
|
66
|
+
end
|
67
|
+
|
68
|
+
# Public: Gets the combined text of the visible fragments of the email body.
|
69
|
+
#
|
70
|
+
# Returns a String.
|
71
|
+
def visible_text
|
72
|
+
fragments.select{|f| !f.hidden?}.map{|f| f.to_s}.join("\n").rstrip
|
73
|
+
end
|
74
|
+
|
75
|
+
def new_content
|
76
|
+
fragments.select{|f| !f.quoted? && !f.hidden? && !f.signature?}.map{|f| f.to_s}.join("\n").rstrip
|
77
|
+
end
|
78
|
+
|
79
|
+
# Splits the given text into a list of Fragments. This is roughly done by
|
80
|
+
# reversing the text and parsing from the bottom to the top. This way we
|
81
|
+
# can check for 'On <date>, <author> wrote:' lines above quoted blocks.
|
82
|
+
#
|
83
|
+
# text - A String email body.
|
84
|
+
# from_address - from address of the email (optional)
|
85
|
+
#
|
86
|
+
# Returns this same Email instance.
|
87
|
+
def read(text, from_address = "")
|
88
|
+
# parse out the from name if one exists and save for use later
|
89
|
+
@from_name_raw = parse_raw_name_from_address(from_address)
|
90
|
+
@from_name_normalized = normalize_name(@from_name_raw)
|
91
|
+
@from_email = parse_email_from_address(from_address)
|
92
|
+
|
93
|
+
text = normalize_text(text)
|
94
|
+
|
95
|
+
# The text is reversed initially due to the way we check for hidden
|
96
|
+
# fragments.
|
97
|
+
text = text.reverse
|
98
|
+
|
99
|
+
# This determines if any 'visible' Fragment has been found. Once any
|
100
|
+
# visible Fragment is found, stop looking for hidden ones.
|
101
|
+
@found_visible = false
|
102
|
+
|
103
|
+
# This instance variable points to the current Fragment. If the matched
|
104
|
+
# line fits, it should be added to this Fragment. Otherwise, finish it
|
105
|
+
# and start a new Fragment.
|
106
|
+
@fragment = nil
|
107
|
+
|
108
|
+
# Use the StringScanner to pull out each line of the email content.
|
109
|
+
@scanner = StringScanner.new(text)
|
110
|
+
while line = @scanner.scan_until(/\n/n)
|
111
|
+
scan_line(line)
|
112
|
+
end
|
113
|
+
|
114
|
+
# Be sure to parse the last line of the email.
|
115
|
+
if (last_line = @scanner.rest.to_s).size > 0
|
116
|
+
scan_line(last_line, true)
|
117
|
+
end
|
118
|
+
|
119
|
+
# Finish up the final fragment. Finishing a fragment will detect any
|
120
|
+
# attributes (hidden, signature, reply), and join each line into a
|
121
|
+
# string.
|
122
|
+
finish_fragment
|
123
|
+
|
124
|
+
@scanner = @fragment = nil
|
125
|
+
|
126
|
+
# Now that parsing is done, reverse the order.
|
127
|
+
@fragments.reverse!
|
128
|
+
self
|
129
|
+
end
|
130
|
+
|
131
|
+
private
|
132
|
+
EMPTY = "".freeze
|
133
|
+
|
134
|
+
COMMON_REPLY_HEADER_REGEXES = [
|
135
|
+
/^On(.+)wrote:$/nm,
|
136
|
+
/^Am(.+)schrieb(.+):$/nm,
|
137
|
+
/^Le(.+)a(.+)crit( )?:$/nm,
|
138
|
+
/\A\d{4}\/\d{1,2}\/\d{1,2}\s+.{1,80}\s<[^@]+@[^@]+>\Z/,
|
139
|
+
]
|
140
|
+
|
141
|
+
# Line optionally starts with whitespace, contains two or more hyphens or
|
142
|
+
# underscores, and ends with optional whitespace.
|
143
|
+
# Example: '---' or '___' or '--- '
|
144
|
+
MULTI_LINE_SIGNATURE_REGEX = /^\s*[-_]{2,}\s*$/
|
145
|
+
|
146
|
+
# Line optionally starts with whitespace, followed by one hyphen, followed by a word character
|
147
|
+
# Example: '-Sandro'
|
148
|
+
ONE_LINE_SIGNATURE_REGEX = /^\s*-\w/
|
149
|
+
|
150
|
+
ORIGINAL_MESSAGE_SIGNATURE_REGEXES = [
|
151
|
+
/^[\s_-]+(Original Message)?[\s_-]+$/,
|
152
|
+
/^[\s_-]+(Original Nachricht)?[\s_-]+$/,
|
153
|
+
/^[\s_-]+(Reply Message)?[\s_-]+$/,
|
154
|
+
/^[\s_-](.+)(ngliche Nachricht)[\s_-]+$/,
|
155
|
+
]
|
156
|
+
|
157
|
+
# No block-quotes (> or <), followed by up to three words, followed by "Sent from my".
|
158
|
+
# Example: "Sent from my iPhone 3G"
|
159
|
+
SENT_FROM_REGEXES = [
|
160
|
+
/^Sent from my (\s*\w+){1,3}(\s*<.*>)?$/,
|
161
|
+
/^Von (\s*\w+){1,3}(\s*<.*>)? gesendet$/,
|
162
|
+
/^Gesendet von meinem (\s*\w+){1,3}(\s*<.*>)?$/,
|
163
|
+
/^Gesendet mit meinem (\s*\w+){1,3}(\s*<.*>)?$/,
|
164
|
+
]
|
165
|
+
|
166
|
+
if defined?(Regexp::NOENCODING)
|
167
|
+
SIGNATURE_REGEX = Regexp.new(Regexp.union(MULTI_LINE_SIGNATURE_REGEX, ONE_LINE_SIGNATURE_REGEX, Regexp.union(ORIGINAL_MESSAGE_SIGNATURE_REGEXES), Regexp.union(SENT_FROM_REGEXES)).source, Regexp::NOENCODING)
|
168
|
+
else
|
169
|
+
SIGNATURE_REGEX = Regexp.new(Regexp.union(MULTI_LINE_SIGNATURE_REGEX, ONE_LINE_SIGNATURE_REGEX, Regexp.union(ORIGINAL_MESSAGE_SIGNATURE_REGEXES), Regexp.union(SENT_FROM_REGEXES)).source)
|
170
|
+
end
|
171
|
+
|
172
|
+
# TODO: refactor out in a i18n.yml file
|
173
|
+
# Supports English, French, Es-Mexican, Pt-Brazilian
|
174
|
+
# Maps a label to a label-group
|
175
|
+
QUOTE_HEADER_LABELS = Hash[*{
|
176
|
+
:from => ["From", "De", "Von"],
|
177
|
+
:to => ["To", "Para", "A", "An"],
|
178
|
+
:cc => ["CC"],
|
179
|
+
:reply_to => ["Reply-To"],
|
180
|
+
:date => ["Date", "Sent", "Gesendet", "Enviado", "Enviada em", "Fecha", "Datum"],
|
181
|
+
:subject => ["Subject", "Assunto", "Asunto", "Objet", "Betreff"]
|
182
|
+
}.map {|group, labels| labels.map {|label| [label.downcase, group]}}.flatten]
|
183
|
+
|
184
|
+
# normalize text so it is easier to parse
|
185
|
+
#
|
186
|
+
# text - text to normalize
|
187
|
+
#
|
188
|
+
# Returns a String
|
189
|
+
def normalize_text(text)
|
190
|
+
# in 1.9 we want to operate on the raw bytes
|
191
|
+
text = text.dup.force_encoding('binary') if text.respond_to?(:force_encoding)
|
192
|
+
|
193
|
+
# Normalize line endings.
|
194
|
+
text.gsub!("\r\n", "\n")
|
195
|
+
|
196
|
+
# Check for multi-line reply headers. Some clients break up
|
197
|
+
# the "On DATE, NAME <EMAIL> wrote:" line into multiple lines.
|
198
|
+
if match = text.match(/^(On\s(.+)wrote:)$/m)
|
199
|
+
# Remove all new lines from the reply header. as long as we don't have any double newline
|
200
|
+
# if we do they we have grabbed something that is not actually a reply header
|
201
|
+
text.gsub! match[1], match[1].gsub("\n", " ") unless match[1] =~ /\n\n/
|
202
|
+
end
|
203
|
+
|
204
|
+
# Some users may reply directly above a line of underscores or dashes.
|
205
|
+
# In order to ensure that these fragments are split correctly,
|
206
|
+
# make sure that all lines of underscores are preceded by
|
207
|
+
# at least two newline characters.
|
208
|
+
text.gsub!(/([^\n])(?=\n_{7}_+)$/m, "\\1\n")
|
209
|
+
text.gsub!(/([^\n])(?=\n-+)$/m, "\\1\n")
|
210
|
+
|
211
|
+
text
|
212
|
+
end
|
213
|
+
|
214
|
+
# Parse a person's name from an e-mail address
|
215
|
+
#
|
216
|
+
# email - email address.
|
217
|
+
#
|
218
|
+
# Returns a String.
|
219
|
+
def parse_name_from_address(address)
|
220
|
+
normalize_name(parse_raw_name_from_address(address))
|
221
|
+
end
|
222
|
+
|
223
|
+
def parse_raw_name_from_address(address)
|
224
|
+
match = address.match(/^["']*([\w\s,]+)["']*\s*</)
|
225
|
+
match ? match[1].strip.to_s : EMPTY
|
226
|
+
end
|
227
|
+
|
228
|
+
def parse_email_from_address(address)
|
229
|
+
match = address.match /<(.*)>/
|
230
|
+
match ? match[1] : address
|
231
|
+
end
|
232
|
+
|
233
|
+
# Normalize a name to First Last
|
234
|
+
#
|
235
|
+
# name - name to normailze.
|
236
|
+
#
|
237
|
+
# Returns a String.
|
238
|
+
def normalize_name(name)
|
239
|
+
if name.include?(',')
|
240
|
+
make_name_first_then_last(name)
|
241
|
+
else
|
242
|
+
name
|
243
|
+
end
|
244
|
+
end
|
245
|
+
|
246
|
+
def make_name_first_then_last(name)
|
247
|
+
split_name = name.split(',')
|
248
|
+
if split_name[0].include?(" ")
|
249
|
+
split_name[0].to_s
|
250
|
+
else
|
251
|
+
split_name[1].strip + " " + split_name[0].strip
|
252
|
+
end
|
253
|
+
end
|
254
|
+
|
255
|
+
### Line-by-Line Parsing
|
256
|
+
|
257
|
+
# Scans the given line of text and determines which fragment it belongs to.
|
258
|
+
def scan_line(line, last = false)
|
259
|
+
line.chomp!("\n")
|
260
|
+
line.reverse!
|
261
|
+
line.rstrip!
|
262
|
+
|
263
|
+
# Mark the current Fragment as a signature if the current line is empty
|
264
|
+
# and the Fragment starts with a common signature indicator.
|
265
|
+
# Mark the current Fragment as a quote if the current line is empty
|
266
|
+
# and the Fragment starts with a multiline quote header.
|
267
|
+
scan_signature_or_quote if @fragment && line == EMPTY
|
268
|
+
|
269
|
+
# We're looking for leading `>`'s to see if this line is part of a
|
270
|
+
# quoted Fragment.
|
271
|
+
is_quoted = !!(line =~ /^>+/n)
|
272
|
+
|
273
|
+
# Note that a common reply header also counts as part of the quoted
|
274
|
+
# Fragment, even though it doesn't start with `>`.
|
275
|
+
unless @fragment &&
|
276
|
+
((@fragment.quoted? == is_quoted) ||
|
277
|
+
(@fragment.quoted? && (line_is_reply_header?(line) || line == EMPTY)))
|
278
|
+
finish_fragment
|
279
|
+
@fragment = Fragment.new
|
280
|
+
@fragment.quoted = is_quoted
|
281
|
+
end
|
282
|
+
|
283
|
+
@fragment.add_line(line)
|
284
|
+
scan_signature_or_quote if last
|
285
|
+
end
|
286
|
+
|
287
|
+
def scan_signature_or_quote
|
288
|
+
if signature_line?(@fragment.lines.first)
|
289
|
+
@fragment.signature = true
|
290
|
+
finish_fragment
|
291
|
+
elsif multiline_quote_header_in_fragment?
|
292
|
+
@fragment.quoted = true
|
293
|
+
finish_fragment
|
294
|
+
end
|
295
|
+
end
|
296
|
+
|
297
|
+
# Returns +true+ if the current block in the current fragment has
|
298
|
+
# a multiline quote header, +false+ otherwise.
|
299
|
+
#
|
300
|
+
# The quote header we're looking for is mainly generated by Outlook
|
301
|
+
# clients. It's considered a quote header if the first 4 folded lines
|
302
|
+
# have one of the following forms:
|
303
|
+
#
|
304
|
+
# label: some text
|
305
|
+
# *label:* some text
|
306
|
+
#
|
307
|
+
# where a line like this:
|
308
|
+
#
|
309
|
+
# label: some text
|
310
|
+
# possibly indented text that belongs to the previous line
|
311
|
+
#
|
312
|
+
# is folded into:
|
313
|
+
#
|
314
|
+
# label: some text possibly indented text that belongs to the previous line
|
315
|
+
#
|
316
|
+
# and where label is a value from +QUOTE_HEADER_LABELS+ that appears
|
317
|
+
# only once in the first 4 lines and where each group of a label
|
318
|
+
# is represented at most once.
|
319
|
+
def multiline_quote_header_in_fragment?
|
320
|
+
folding = false
|
321
|
+
label_groups = []
|
322
|
+
@fragment.current_block.split("\n").each do |line|
|
323
|
+
if line =~ /\A\s*\*?([^:]+):(\s|\*)/
|
324
|
+
label = QUOTE_HEADER_LABELS[$1.downcase]
|
325
|
+
if label
|
326
|
+
return false if label_groups.include?(label)
|
327
|
+
return true if label_groups.length == 3
|
328
|
+
label_groups << label
|
329
|
+
folding = true
|
330
|
+
elsif !folding
|
331
|
+
return false
|
332
|
+
end
|
333
|
+
elsif !folding
|
334
|
+
return false
|
335
|
+
else
|
336
|
+
folding = true
|
337
|
+
end
|
338
|
+
end
|
339
|
+
return false
|
340
|
+
end
|
341
|
+
|
342
|
+
# Detects if a given line is the beginning of a signature
|
343
|
+
#
|
344
|
+
# line - A String line of text from the email.
|
345
|
+
#
|
346
|
+
# Returns true if the line is the beginning of a signature, or false.
|
347
|
+
def signature_line?(line)
|
348
|
+
line =~ SIGNATURE_REGEX || line_is_signature_name?(line)
|
349
|
+
end
|
350
|
+
|
351
|
+
# Detects if a given line is a common reply header.
|
352
|
+
#
|
353
|
+
# line - A String line of text from the email.
|
354
|
+
#
|
355
|
+
# Returns true if the line is a valid header, or false.
|
356
|
+
def line_is_reply_header?(line)
|
357
|
+
COMMON_REPLY_HEADER_REGEXES.each do |regex|
|
358
|
+
return true if line =~ regex
|
359
|
+
end
|
360
|
+
false
|
361
|
+
end
|
362
|
+
|
363
|
+
# Detects if the @from name is a big part of a given line and therefore the beginning of a signature
|
364
|
+
#
|
365
|
+
# line - A String line of text from the email.
|
366
|
+
#
|
367
|
+
# Returns true if @from_name is a big part of the line, or false.
|
368
|
+
def line_is_signature_name?(line)
|
369
|
+
regexp = generate_regexp_for_name()
|
370
|
+
@from_name_normalized != "" && (line =~ regexp) && ((@from_name_normalized.size.to_f / line.size) > 0.25)
|
371
|
+
end
|
372
|
+
|
373
|
+
#generates regexp which always for additional words or initials between first and last names
|
374
|
+
def generate_regexp_for_name
|
375
|
+
name_parts = @from_name_normalized.split(" ")
|
376
|
+
seperator = '[\w.\s]*'
|
377
|
+
regexp = Regexp.new(name_parts.join(seperator), Regexp::IGNORECASE)
|
378
|
+
end
|
379
|
+
|
380
|
+
# Builds the fragment string, after all lines have been added.
|
381
|
+
# It also checks to see if this Fragment is hidden. The hidden
|
382
|
+
# Fragment check reads from the bottom to the top.
|
383
|
+
#
|
384
|
+
# Any quoted Fragments or signature Fragments are marked hidden if they
|
385
|
+
# are below any visible Fragments. Visible Fragments are expected to
|
386
|
+
# contain original content by the author. If they are below a quoted
|
387
|
+
# Fragment, then the Fragment should be visible to give context to the
|
388
|
+
# reply.
|
389
|
+
#
|
390
|
+
# some original text (visible)
|
391
|
+
#
|
392
|
+
# > do you have any two's? (quoted, visible)
|
393
|
+
#
|
394
|
+
# Go fish! (visible)
|
395
|
+
#
|
396
|
+
# > --
|
397
|
+
# > Player 1 (quoted, hidden)
|
398
|
+
#
|
399
|
+
# --
|
400
|
+
# Player 2 (signature, hidden)
|
401
|
+
#
|
402
|
+
def finish_fragment
|
403
|
+
if @fragment
|
404
|
+
@fragment.finish
|
405
|
+
if !@found_visible
|
406
|
+
if @fragment.quoted? || @fragment.signature? ||
|
407
|
+
@fragment.reply_header? || @fragment.to_s.strip == EMPTY
|
408
|
+
@fragment.hidden = true
|
409
|
+
else
|
410
|
+
@found_visible = true
|
411
|
+
end
|
412
|
+
end
|
413
|
+
@fragments << @fragment
|
414
|
+
end
|
415
|
+
@fragment = nil
|
416
|
+
end
|
417
|
+
end
|
418
|
+
|
419
|
+
# Represents a group of paragraphs in the email sharing common attributes.
|
420
|
+
# Paragraphs should get their own fragment if they are a quoted area or a
|
421
|
+
# signature.
|
422
|
+
class Fragment < Struct.new(:quoted, :signature, :reply_header, :hidden)
|
423
|
+
# Array of string lines that make up the content of this fragment.
|
424
|
+
attr_reader :lines
|
425
|
+
|
426
|
+
# Array of string lines that is being processed not having
|
427
|
+
# an empty line.
|
428
|
+
attr_reader :current_block
|
429
|
+
|
430
|
+
# This is reserved for the joined String that is build when this Fragment
|
431
|
+
# is finished.
|
432
|
+
attr_reader :content
|
433
|
+
|
434
|
+
def initialize
|
435
|
+
self.quoted = self.signature = self.reply_header = self.hidden = false
|
436
|
+
@lines = []
|
437
|
+
@current_block = []
|
438
|
+
@content = nil
|
439
|
+
end
|
440
|
+
|
441
|
+
alias quoted? quoted
|
442
|
+
alias signature? signature
|
443
|
+
alias reply_header? reply_header
|
444
|
+
alias hidden? hidden
|
445
|
+
|
446
|
+
def add_line(line)
|
447
|
+
return unless line
|
448
|
+
@lines.insert(0, line)
|
449
|
+
if line == ""
|
450
|
+
@current_block.clear
|
451
|
+
else
|
452
|
+
@current_block.insert(0, line)
|
453
|
+
end
|
454
|
+
end
|
455
|
+
|
456
|
+
def current_block
|
457
|
+
@current_block.join("\n")
|
458
|
+
end
|
459
|
+
|
460
|
+
# Builds the string content by joining the lines and reversing them.
|
461
|
+
def finish
|
462
|
+
@content = @lines.join("\n")
|
463
|
+
@lines = @current_block = nil
|
464
|
+
end
|
465
|
+
|
466
|
+
def to_s
|
467
|
+
@lines ? @lines.join("\n") : @content
|
468
|
+
end
|
469
|
+
|
470
|
+
def inspect
|
471
|
+
"#{super.inspect} : #{to_s.inspect}"
|
472
|
+
end
|
473
|
+
end
|
474
|
+
end
|
475
|
+
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module Replyr
|
2
|
+
module Generators
|
3
|
+
class InstallGenerator < Rails::Generators::Base
|
4
|
+
def self.source_root
|
5
|
+
File.dirname(__FILE__) + "/templates"
|
6
|
+
end
|
7
|
+
|
8
|
+
def copy_files
|
9
|
+
copy_file "mailman_server", "script/mailman_server"
|
10
|
+
copy_file "mailman_daemon", "script/mailman_daemon"
|
11
|
+
template "replyr.rb.erb", "config/initializers/replyr.rb"
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|