internet_message 0.1
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.
- data/lib/internet_message/address.rb +35 -0
- data/lib/internet_message/content_attribute.rb +54 -0
- data/lib/internet_message/content_disposition.rb +37 -0
- data/lib/internet_message/content_type.rb +38 -0
- data/lib/internet_message/group.rb +49 -0
- data/lib/internet_message/header_field.rb +129 -0
- data/lib/internet_message/mailbox.rb +102 -0
- data/lib/internet_message/message_id.rb +47 -0
- data/lib/internet_message/received.rb +59 -0
- data/lib/internet_message/tokenizer.rb +61 -0
- data/lib/internet_message.rb +457 -0
- data/spec/internet_message/address_spec.rb +34 -0
- data/spec/internet_message/group_spec.rb +60 -0
- data/spec/internet_message/mailbox_spec.rb +138 -0
- data/spec/internet_message/received_spec.rb +49 -0
- data/spec/internet_message/tokenizer_spec.rb +59 -0
- data/spec/internet_message_spec.rb +573 -0
- metadata +74 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
class InternetMessage
|
|
2
|
+
class Address
|
|
3
|
+
|
|
4
|
+
attr_reader :local_part, :domain
|
|
5
|
+
|
|
6
|
+
# @param [String] local_part local part of mail address
|
|
7
|
+
# @param [String] domain domain part of mail address
|
|
8
|
+
def initialize(local_part, domain)
|
|
9
|
+
@local_part, @domain = local_part, domain
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# @return [String] mail address
|
|
13
|
+
def to_s
|
|
14
|
+
if @local_part =~ /\A[0-9a-zA-Z\!\#\$\%\'\*\+\-\/\=\?\^\_\`\{\|\}\~]+(\.[0-9a-zA-Z\!\#\$\%\'\*\+\-\/\=\?\^\_\`\{\|\}\~]+)*\z/n
|
|
15
|
+
l = @local_part
|
|
16
|
+
else
|
|
17
|
+
l = quote_string(@local_part)
|
|
18
|
+
end
|
|
19
|
+
"#{l}@#{@domain}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @private
|
|
23
|
+
def quote_string(s)
|
|
24
|
+
'"'+s.gsub(/[\\\"]/){"\\#{$&}"}+'"'
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Compare self to other. local_part and domain are case insensitive.
|
|
28
|
+
# @param [Address] other
|
|
29
|
+
# @return [true, false]
|
|
30
|
+
def ==(other)
|
|
31
|
+
other.is_a?(Address) && other.local_part.downcase == self.local_part.downcase && other.domain.downcase == self.domain.downcase
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
class InternetMessage
|
|
2
|
+
# @private
|
|
3
|
+
module ContentAttribute
|
|
4
|
+
def self.parse_attribute(tokens)
|
|
5
|
+
attr = {}
|
|
6
|
+
until tokens.empty?
|
|
7
|
+
break unless tokens.size >= 4 && tokens[0].value == ';' && tokens[2].value == '='
|
|
8
|
+
attr[tokens[1].value.downcase] = tokens[3].value
|
|
9
|
+
tokens.shift 4
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
newattr = {}
|
|
13
|
+
h = Hash.new{|hash,k| hash[k] = []}
|
|
14
|
+
char_lang = {}
|
|
15
|
+
attr.each do |key, value|
|
|
16
|
+
case key
|
|
17
|
+
when /^([^\*]+)(\*0)?\*$/no
|
|
18
|
+
name, ord = $1, $2
|
|
19
|
+
char, lang, v = value.split(/\'/, 3)
|
|
20
|
+
char_lang[name] = [char, lang]
|
|
21
|
+
if v.nil?
|
|
22
|
+
v = lang || char
|
|
23
|
+
end
|
|
24
|
+
v = v.gsub(/%([0-9A-F][0-9A-F])/ni){$1.hex.chr}
|
|
25
|
+
if ord
|
|
26
|
+
h[name] << [0, v]
|
|
27
|
+
else
|
|
28
|
+
newattr[name] = v
|
|
29
|
+
end
|
|
30
|
+
when /^([^\*]+)\*([1-9]\d*)\*$/no
|
|
31
|
+
name, ord = $1, $2.to_i
|
|
32
|
+
v = value.gsub(/%([0-9A-F][0-9A-F])/ni){$1.hex.chr}
|
|
33
|
+
h[name] << [ord, v]
|
|
34
|
+
when /^([^\*]+)\*([0-9]\d*)$/no
|
|
35
|
+
name, ord = $1, $2.to_i
|
|
36
|
+
h[name] << [ord, value]
|
|
37
|
+
else
|
|
38
|
+
newattr[key] = value
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
h.each do |k, v|
|
|
42
|
+
newattr[k] = v.sort{|a,b| a[0]<=>b[0]}.map{|a| a[1]}.join
|
|
43
|
+
end
|
|
44
|
+
newattr.keys.each do |k|
|
|
45
|
+
v = newattr[k]
|
|
46
|
+
if char_lang.key? k
|
|
47
|
+
v.force_encoding(char_lang[k][0]) rescue nil
|
|
48
|
+
end
|
|
49
|
+
newattr[k] = v
|
|
50
|
+
end
|
|
51
|
+
return newattr
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
require "#{File.dirname __FILE__}/tokenizer"
|
|
2
|
+
require "#{File.dirname __FILE__}/content_attribute"
|
|
3
|
+
|
|
4
|
+
class InternetMessage
|
|
5
|
+
class ContentDisposition
|
|
6
|
+
|
|
7
|
+
TOKEN_RE = /[0-9a-zA-Z\!\#\$\%\&\'\*\+\-\.\^\_\`\{\|\}\~]+/i
|
|
8
|
+
|
|
9
|
+
# @param [String, Array of Tokenizer] src
|
|
10
|
+
# @return [ContentDisposition]
|
|
11
|
+
def self.parse(src)
|
|
12
|
+
tokens = src.is_a?(String) ? Tokenizer.new(src, :token_re=>TOKEN_RE).tokenize : src.dup
|
|
13
|
+
tokens.delete_if{|t| t.type == :WSP or t.type == :COMMENT}
|
|
14
|
+
unless tokens.size >= 1 && tokens[0].type == :TOKEN
|
|
15
|
+
return nil
|
|
16
|
+
end
|
|
17
|
+
type = tokens[0].value
|
|
18
|
+
tokens.shift
|
|
19
|
+
ContentDisposition.new(type, ContentAttribute.parse_attribute(tokens))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
attr_reader :type, :attribute
|
|
23
|
+
|
|
24
|
+
# @param [String] type
|
|
25
|
+
# @param [Hash] attribute
|
|
26
|
+
def initialize(type, attribute={})
|
|
27
|
+
@type, @attribute = type.downcase, attribute
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Compare self and other.
|
|
31
|
+
# @param [ContentDisposition] other
|
|
32
|
+
# @return [true, false]
|
|
33
|
+
def ==(other)
|
|
34
|
+
other.is_a?(ContentDisposition) && other.type == self.type && other.attribute == self.attribute
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
require "#{File.dirname __FILE__}/tokenizer"
|
|
2
|
+
require "#{File.dirname __FILE__}/content_attribute"
|
|
3
|
+
|
|
4
|
+
class InternetMessage
|
|
5
|
+
class ContentType
|
|
6
|
+
|
|
7
|
+
TOKEN_RE = /[0-9a-zA-Z\!\#\$\%\&\'\*\+\-\.\^\_\`\{\|\}\~]+/i
|
|
8
|
+
|
|
9
|
+
# @param [String, Array of Tokenizer] src
|
|
10
|
+
# @return [ContentType]
|
|
11
|
+
def self.parse(src)
|
|
12
|
+
tokens = src.is_a?(String) ? Tokenizer.new(src, :token_re=>TOKEN_RE).tokenize : src.dup
|
|
13
|
+
tokens.delete_if{|t| t.type == :WSP or t.type == :COMMENT}
|
|
14
|
+
unless tokens.size >= 3 && tokens[0].type == :TOKEN && tokens[1].value == '/' && tokens[2].type == :TOKEN
|
|
15
|
+
return nil
|
|
16
|
+
end
|
|
17
|
+
type, subtype = tokens[0].value, tokens[2].value
|
|
18
|
+
tokens.shift 3
|
|
19
|
+
ContentType.new(type, subtype, ContentAttribute.parse_attribute(tokens))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
attr_reader :type, :subtype, :attribute
|
|
23
|
+
|
|
24
|
+
# @param [String] type
|
|
25
|
+
# @param [String] subtype
|
|
26
|
+
# @param [Hash] attribute
|
|
27
|
+
def initialize(type, subtype, attribute={})
|
|
28
|
+
@type, @subtype, @attribute = type.downcase, subtype.downcase, attribute
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Compare self and other
|
|
32
|
+
# @param [ContentType] other
|
|
33
|
+
# @return [true, false]
|
|
34
|
+
def ==(other)
|
|
35
|
+
other.is_a?(ContentType) && other.type == self.type && other.subtype == self.subtype && other.attribute == self.attribute
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
require "#{File.dirname __FILE__}/tokenizer"
|
|
2
|
+
|
|
3
|
+
class InternetMessage
|
|
4
|
+
class Group
|
|
5
|
+
# @param [String, Array of Tokenizer] src
|
|
6
|
+
# @param [true, false] decode_mime_header Set true to decode MIME header (RFC2047).
|
|
7
|
+
# @return [Group]
|
|
8
|
+
def self.parse(src, decode_mime_header=nil)
|
|
9
|
+
tokens = src.is_a?(String) ? Tokenizer.new(src).tokenize : src.dup
|
|
10
|
+
tokens.delete_if{|t| t.type == :WSP or t.type == :COMMENT}
|
|
11
|
+
i = tokens.index(Token.new(:CHAR, ':'))
|
|
12
|
+
j = tokens.index(Token.new(:CHAR, ';')) || tokens.size
|
|
13
|
+
if i and i < j
|
|
14
|
+
disp_tokens = tokens[0..i-1]
|
|
15
|
+
display_name = i == 0 ? '' : decode_mime_header ? InternetMessage.decode_mime_header_words(disp_tokens) : disp_tokens.join(' ')
|
|
16
|
+
mailbox_list = Mailbox.parse_list(tokens[i+1..j-1], decode_mime_header)
|
|
17
|
+
Group.new(display_name, mailbox_list)
|
|
18
|
+
else
|
|
19
|
+
Group.new('', Mailbox.parse_list(tokens))
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
attr_reader :mailbox_list, :display_name
|
|
24
|
+
|
|
25
|
+
# @param [String] display_name
|
|
26
|
+
# @param [Array of Mailbox] mailbox_list
|
|
27
|
+
def initialize(display_name, mailbox_list)
|
|
28
|
+
@display_name, @mailbox_list = display_name, mailbox_list
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @private
|
|
32
|
+
def to_s
|
|
33
|
+
d = @display_name.split(/[ \t]+/).map do |w|
|
|
34
|
+
if w =~ /\A[0-9a-zA-Z\!\#\$\%\'\*\+\-\/\=\?\^\_\`\{\|\}\~]+\z/n
|
|
35
|
+
w
|
|
36
|
+
else
|
|
37
|
+
quote_string w
|
|
38
|
+
end
|
|
39
|
+
end.join(' ')
|
|
40
|
+
"#{d}: "+mailbox_list.join(', ')+';'
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def quote_string(s)
|
|
46
|
+
'"'+s.gsub(/[\\\"]/){"\\#{$&}"}+'"'
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
class InternetMessage
|
|
2
|
+
class HeaderField
|
|
3
|
+
attr_reader :name, :orig_value, :raw
|
|
4
|
+
|
|
5
|
+
# @param [String] name field name
|
|
6
|
+
# @param [MmapScanner] value field value
|
|
7
|
+
# @param [MmapScanner] raw field line
|
|
8
|
+
def initialize(name, value, raw)
|
|
9
|
+
@name, @orig_value, @raw = name, value, raw
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# @return [String] value as String
|
|
13
|
+
def value
|
|
14
|
+
@orig_value.to_s
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @param [true, false] decode_mime_header Set true to decode MIME header (RFC2047).
|
|
18
|
+
# @return parsed value
|
|
19
|
+
def parse(decode_mime_header=nil)
|
|
20
|
+
case @name
|
|
21
|
+
when 'date', 'resent-date'
|
|
22
|
+
DateTime.parse(value.gsub(/\r?\n/, '')) rescue nil
|
|
23
|
+
when 'from', 'resent-from'
|
|
24
|
+
self.class.parse_mailboxlist(value, decode_mime_header)
|
|
25
|
+
when 'from', 'sender', 'resent-from', 'resent-sender'
|
|
26
|
+
Mailbox.parse(value, decode_mime_header)
|
|
27
|
+
when 'message-id', 'content-id', 'resent-message-id'
|
|
28
|
+
MessageId.parse(value)
|
|
29
|
+
when 'in-reply-to', 'references'
|
|
30
|
+
MessageId.parse_list(value)
|
|
31
|
+
when 'mime-version', 'content-transfer-encoding'
|
|
32
|
+
tokens = Tokenizer.new(value).tokenize2
|
|
33
|
+
tokens.empty? ? nil : tokens.join
|
|
34
|
+
when 'content-type'
|
|
35
|
+
ContentType.parse(value)
|
|
36
|
+
when 'content-disposition'
|
|
37
|
+
ContentDisposition.parse(value)
|
|
38
|
+
when 'reply-to', 'to', 'cc', 'bcc', 'resent-to', 'resent-cc', 'resent-bcc'
|
|
39
|
+
self.class.parse_addrlist(value, decode_mime_header)
|
|
40
|
+
when 'keywords'
|
|
41
|
+
self.class.parse_keywords(value, decode_mime_header)
|
|
42
|
+
when 'return-path'
|
|
43
|
+
self.class.parse_return_path(value)
|
|
44
|
+
when 'received'
|
|
45
|
+
Received.parse value
|
|
46
|
+
else
|
|
47
|
+
s = value.gsub(/\r?\n/, '')
|
|
48
|
+
decode_mime_header ? InternetMessage.decode_mime_header_str(s) : s
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @private
|
|
53
|
+
def self.parse_mailboxlist(str, decode_mime_header=nil)
|
|
54
|
+
ret = []
|
|
55
|
+
tokens = Tokenizer.new(str).tokenize2
|
|
56
|
+
until tokens.empty?
|
|
57
|
+
i = tokens.index(Token.new(:CHAR, ','))
|
|
58
|
+
if i == 0
|
|
59
|
+
tokens.shift
|
|
60
|
+
next
|
|
61
|
+
end
|
|
62
|
+
if i
|
|
63
|
+
ret.push Mailbox.parse(tokens.slice!(0..i-1), decode_mime_header)
|
|
64
|
+
else
|
|
65
|
+
ret.push Mailbox.parse(tokens, decode_mime_header)
|
|
66
|
+
tokens.clear
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
ret
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @private
|
|
73
|
+
def self.parse_addrlist(str, decode_mime_header=nil)
|
|
74
|
+
ret = []
|
|
75
|
+
tokens = Tokenizer.new(str).tokenize2
|
|
76
|
+
until tokens.empty?
|
|
77
|
+
i = tokens.index(Token.new(:CHAR, ','))
|
|
78
|
+
if i == 0
|
|
79
|
+
tokens.shift
|
|
80
|
+
next
|
|
81
|
+
end
|
|
82
|
+
j = tokens.index(Token.new(:CHAR, ':'))
|
|
83
|
+
if i && j && j < i || !i && j
|
|
84
|
+
i = tokens.index(Token.new(:CHAR, ';')) || -1
|
|
85
|
+
ret.push Group.parse(tokens.slice!(0..i), decode_mime_header)
|
|
86
|
+
elsif i
|
|
87
|
+
ret.push Mailbox.parse(tokens.slice!(0..i-1), decode_mime_header)
|
|
88
|
+
else
|
|
89
|
+
ret.push Mailbox.parse(tokens, decode_mime_header)
|
|
90
|
+
tokens.clear
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
ret
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# @private
|
|
97
|
+
def self.parse_keywords(str, decode_mime_header=nil)
|
|
98
|
+
keys = []
|
|
99
|
+
tokens = Tokenizer.new(str).tokenize2
|
|
100
|
+
while true
|
|
101
|
+
i = tokens.index(Token.new(:CHAR, ','))
|
|
102
|
+
break unless i
|
|
103
|
+
if i > 0
|
|
104
|
+
key = decode_mime_header ? InternetMessage.decode_mime_header_words(tokens[0, i]) : tokens[0, i].join(' ')
|
|
105
|
+
keys.push key
|
|
106
|
+
end
|
|
107
|
+
tokens.shift i+1
|
|
108
|
+
end
|
|
109
|
+
unless tokens.empty?
|
|
110
|
+
key = decode_mime_header ? InternetMessage.decode_mime_header_words(tokens) : tokens.join(' ')
|
|
111
|
+
keys.push key
|
|
112
|
+
end
|
|
113
|
+
keys
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# @private
|
|
117
|
+
def self.parse_return_path(str)
|
|
118
|
+
tokens = Tokenizer.new(str).tokenize2
|
|
119
|
+
i = tokens.index(Token.new(:CHAR, '<'))
|
|
120
|
+
return unless i
|
|
121
|
+
tokens.shift i+1
|
|
122
|
+
i = tokens.index(Token.new(:CHAR, '>'))
|
|
123
|
+
return unless i
|
|
124
|
+
tokens = tokens[0, i]
|
|
125
|
+
i = tokens.rindex(Token.new(:CHAR, '@'))
|
|
126
|
+
i && Address.new(tokens[0, i].join, tokens[i+1..-1].join)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
require "#{File.dirname __FILE__}/tokenizer"
|
|
2
|
+
require "#{File.dirname __FILE__}/address"
|
|
3
|
+
|
|
4
|
+
class InternetMessage
|
|
5
|
+
class Mailbox
|
|
6
|
+
# @param [String, Array of Tokenizer] src
|
|
7
|
+
# @param [true, false] decode_mime_header Set true to decode MIME header (RFC2047).
|
|
8
|
+
# @return [Mailbox]
|
|
9
|
+
def self.parse(src, decode_mime_header=nil)
|
|
10
|
+
tokens = src.is_a?(String) ? Tokenizer.new(src).tokenize : src.dup
|
|
11
|
+
tokens.delete_if{|t| t.type == :WSP or t.type == :COMMENT}
|
|
12
|
+
if i = tokens.index(Token.new(:CHAR, '<'))
|
|
13
|
+
display_name = decode_mime_header ? InternetMessage.decode_mime_header_words(tokens[0..i-1]) : tokens[0..i-1].join(' ')
|
|
14
|
+
if j = tokens.index(Token.new(:CHAR, '>'))
|
|
15
|
+
tokens = tokens[i+1..j-1]
|
|
16
|
+
else
|
|
17
|
+
tokens = tokens[i+1..-1]
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
i = tokens.rindex(Token.new(:CHAR, '@'))
|
|
21
|
+
return unless i
|
|
22
|
+
local = i == 0 ? '' : tokens[0..i-1].join
|
|
23
|
+
domain = tokens[i+1..-1].join
|
|
24
|
+
Mailbox.new(Address.new(local, domain), display_name)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @param [String, Array of Tokenizer] src
|
|
28
|
+
# @param [true, false] decode_mime_header Set true to decode MIME header (RFC2047).
|
|
29
|
+
# @return [Array of Mailbox]
|
|
30
|
+
def self.parse_list(src, decode_mime_header=nil)
|
|
31
|
+
tokens = src.is_a?(String) ? Tokenizer.new(src).tokenize : src.dup
|
|
32
|
+
ret = []
|
|
33
|
+
until tokens.empty?
|
|
34
|
+
i = tokens.index(Token.new(:CHAR, ','))
|
|
35
|
+
break unless i
|
|
36
|
+
if i > 0
|
|
37
|
+
ret.push self.parse(tokens.slice!(0, i), decode_mime_header)
|
|
38
|
+
end
|
|
39
|
+
tokens.shift
|
|
40
|
+
end
|
|
41
|
+
ret.push self.parse(tokens, decode_mime_header) unless tokens.empty?
|
|
42
|
+
ret
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
attr_reader :address, :display_name
|
|
46
|
+
|
|
47
|
+
# @overload initialize(addr, display_name=nil)
|
|
48
|
+
# @param [Address] addr
|
|
49
|
+
# @param [String] display_name
|
|
50
|
+
# @overload initialize(local_part, domain, display_name=nil)
|
|
51
|
+
# @param [String] local_part
|
|
52
|
+
# @param [String] domain
|
|
53
|
+
# @param [String] display_name
|
|
54
|
+
def initialize(addr, *args)
|
|
55
|
+
if addr.is_a? Address and args.size <= 1
|
|
56
|
+
@address = addr
|
|
57
|
+
@display_name = args.first
|
|
58
|
+
elsif args.size >= 1 and args.size <= 2
|
|
59
|
+
@address = Address.new(addr, args[0])
|
|
60
|
+
@display_name = args[1]
|
|
61
|
+
else
|
|
62
|
+
raise ArgumentError
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @return [String] local_part
|
|
67
|
+
def local_part
|
|
68
|
+
@address.local_part
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @return [String] domain
|
|
72
|
+
def domain
|
|
73
|
+
@address.domain
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @private
|
|
77
|
+
def to_s
|
|
78
|
+
if @display_name
|
|
79
|
+
d = @display_name.split(/[ \t]+/).map do |w|
|
|
80
|
+
if w =~ /\A[0-9a-zA-Z\!\#\$\%\'\*\+\-\/\=\?\^\_\`\{\|\}\~]+\z/n
|
|
81
|
+
w
|
|
82
|
+
else
|
|
83
|
+
quote_string w
|
|
84
|
+
end
|
|
85
|
+
end.join(' ')
|
|
86
|
+
"#{d} <#{@address.to_s}>"
|
|
87
|
+
else
|
|
88
|
+
@address.to_s
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def quote_string(s)
|
|
95
|
+
'"'+s.gsub(/[\\\"]/){"\\#{$&}"}+'"'
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def ==(other)
|
|
99
|
+
other.is_a?(Mailbox) && other.address == self.address && other.display_name == self.display_name
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
require "#{File.dirname __FILE__}/tokenizer"
|
|
2
|
+
|
|
3
|
+
class InternetMessage
|
|
4
|
+
class MessageId
|
|
5
|
+
# @param [String, Array of Tokenizer] src
|
|
6
|
+
# @return [MessageId]
|
|
7
|
+
def self.parse(str)
|
|
8
|
+
tokens = Tokenizer.new(str).tokenize2
|
|
9
|
+
i = tokens.index(Token.new(:CHAR, '<'))
|
|
10
|
+
return unless i
|
|
11
|
+
tokens.shift i+1
|
|
12
|
+
i = tokens.index(Token.new(:CHAR, '>'))
|
|
13
|
+
return unless i
|
|
14
|
+
self.new tokens[0, i].join
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @param [String, Array of Tokenizer] src
|
|
18
|
+
# @return [Array of MessageId]
|
|
19
|
+
def self.parse_list(str)
|
|
20
|
+
tokens = Tokenizer.new(str).tokenize2
|
|
21
|
+
ret = []
|
|
22
|
+
while true
|
|
23
|
+
i = tokens.index(Token.new(:CHAR, '<'))
|
|
24
|
+
break unless i
|
|
25
|
+
tokens.shift i+1
|
|
26
|
+
i = tokens.index(Token.new(:CHAR, '>'))
|
|
27
|
+
break unless i
|
|
28
|
+
ret.push MessageId.new(tokens[0, i].join)
|
|
29
|
+
end
|
|
30
|
+
ret
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
attr_reader :msgid
|
|
34
|
+
|
|
35
|
+
# @param [String] msgid
|
|
36
|
+
def initialize(msgid)
|
|
37
|
+
@msgid = msgid
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Compare self to other
|
|
41
|
+
# @param [String] other
|
|
42
|
+
# @return [true, false]
|
|
43
|
+
def ==(other)
|
|
44
|
+
other.is_a?(MessageId) && other.msgid == self.msgid
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
require 'date'
|
|
2
|
+
require "#{File.dirname __FILE__}/tokenizer"
|
|
3
|
+
|
|
4
|
+
class InternetMessage
|
|
5
|
+
class Received
|
|
6
|
+
# @param [String, Array of Tokenizer] src
|
|
7
|
+
# @return [Received]
|
|
8
|
+
def self.parse(src)
|
|
9
|
+
tokens = src.is_a?(String) ? Tokenizer.new(src).tokenize : src.dup
|
|
10
|
+
i = tokens.index(Token.new(:CHAR, ';'))
|
|
11
|
+
return unless i
|
|
12
|
+
date = DateTime.parse(tokens[i+1..-1].join) rescue nil
|
|
13
|
+
tokens = tokens[0, i]
|
|
14
|
+
|
|
15
|
+
list = tokens.inject([[]]){|r, t|
|
|
16
|
+
if t.type == :WSP or t.type == :COMMENT
|
|
17
|
+
r.push []
|
|
18
|
+
else
|
|
19
|
+
r.last.push t
|
|
20
|
+
end
|
|
21
|
+
r
|
|
22
|
+
}.reject(&:empty?)
|
|
23
|
+
|
|
24
|
+
while list.size >= 2
|
|
25
|
+
case list.shift.join.downcase
|
|
26
|
+
when 'from'
|
|
27
|
+
from = list.shift.join
|
|
28
|
+
when 'by'
|
|
29
|
+
by = list.shift.join
|
|
30
|
+
when 'via'
|
|
31
|
+
via = list.shift.join
|
|
32
|
+
when 'with'
|
|
33
|
+
with = list.shift.join
|
|
34
|
+
when 'id'
|
|
35
|
+
id = list.shift.join
|
|
36
|
+
when 'for'
|
|
37
|
+
m = Mailbox.parse(list.shift)
|
|
38
|
+
for_ = m && m.address
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
self.new(from, by, via, with, id, for_, date)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
attr_reader :from, :by, :via, :with, :id, :for, :date
|
|
45
|
+
|
|
46
|
+
# @param [String] from
|
|
47
|
+
# @param [String] by
|
|
48
|
+
# @param [String] via
|
|
49
|
+
# @param [String] with
|
|
50
|
+
# @param [String] id
|
|
51
|
+
# @param [Mailbox] for_
|
|
52
|
+
# @param [DateTime] date
|
|
53
|
+
def initialize(from, by, via, with, id, for_, date)
|
|
54
|
+
@from, @by, @via, @with, @id, @for, @date =
|
|
55
|
+
from, by, via, with, id, for_, date
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
require 'strscan'
|
|
2
|
+
|
|
3
|
+
class InternetMessage
|
|
4
|
+
class Tokenizer
|
|
5
|
+
TOKEN_RE = /[0-9a-zA-Z\!\#\$\%\'\*\+\-\/\=\?\^\_\`\{\|\}\~\.]+/n
|
|
6
|
+
|
|
7
|
+
def initialize(s, opt={})
|
|
8
|
+
@ss = StringScanner.new(s.gsub(/\r?\n/, ''))
|
|
9
|
+
@token_re = opt[:token_re] || TOKEN_RE
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def tokenize(opt={})
|
|
13
|
+
ret = []
|
|
14
|
+
until @ss.eos?
|
|
15
|
+
case
|
|
16
|
+
when s = @ss.scan(/[ \t]+/)
|
|
17
|
+
ret.push Token.new(:WSP, s) unless opt[:skip_wsp]
|
|
18
|
+
when s = @ss.scan(@token_re)
|
|
19
|
+
ret.push Token.new(:TOKEN, s)
|
|
20
|
+
when s = @ss.scan(/\"(\\.|[^\"])+\"/)
|
|
21
|
+
ret.push Token.new(:QUOTED, s.gsub(/\A\"|\"\z/,'').gsub(/\\(.)/){$1})
|
|
22
|
+
when @ss.check(/\(/)
|
|
23
|
+
comment = scan_comment
|
|
24
|
+
ret.push Token.new(:COMMENT, comment) unless opt[:skip_comment]
|
|
25
|
+
else
|
|
26
|
+
ret.push Token.new(:CHAR, @ss.scan(/./))
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
ret
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def tokenize2
|
|
33
|
+
tokenize(:skip_wsp=>true, :skip_comment=>true)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def scan_comment
|
|
37
|
+
ret = []
|
|
38
|
+
@ss.scan(/\(/) or return ret
|
|
39
|
+
until @ss.scan(/\)/) or @ss.eos?
|
|
40
|
+
s = @ss.scan(/(\\.|[^\\\(\)])*/) and ret.push s.gsub(/\\(.)/){$1}
|
|
41
|
+
@ss.check(/\(/) and ret.push scan_comment
|
|
42
|
+
end
|
|
43
|
+
ret
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
class Token
|
|
48
|
+
attr_reader :type, :value
|
|
49
|
+
|
|
50
|
+
def initialize(type, value)
|
|
51
|
+
@type, @value = type, value
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
alias to_s value
|
|
55
|
+
|
|
56
|
+
def ==(other)
|
|
57
|
+
other.is_a?(self.class) && other.type == self.type && other.value == self.value
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
end
|