pec_ruby 0.2.1 → 0.2.3
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/.rspec_status +115 -98
- data/CHANGELOG.md +52 -1
- data/Gemfile.lock +3 -1
- data/README.md +212 -45
- data/lib/pec_ruby/cli.rb +353 -100
- data/lib/pec_ruby/client.rb +77 -29
- data/lib/pec_ruby/message.rb +188 -38
- data/lib/pec_ruby/version.rb +2 -2
- data/lib/pec_ruby.rb +7 -6
- metadata +16 -2
data/lib/pec_ruby/client.rb
CHANGED
@@ -5,7 +5,7 @@ require 'mail'
|
|
5
5
|
|
6
6
|
module PecRuby
|
7
7
|
class Client
|
8
|
-
attr_reader :imap, :host, :username
|
8
|
+
attr_reader :imap, :host, :username, :current_folder
|
9
9
|
|
10
10
|
def initialize(host:, username:, password:, ssl: true)
|
11
11
|
@host = host
|
@@ -13,6 +13,7 @@ module PecRuby
|
|
13
13
|
@password = password
|
14
14
|
@ssl = ssl
|
15
15
|
@imap = nil
|
16
|
+
@current_folder = nil
|
16
17
|
end
|
17
18
|
|
18
19
|
def connect
|
@@ -29,48 +30,105 @@ module PecRuby
|
|
29
30
|
|
30
31
|
begin
|
31
32
|
@imap.logout if @imap && !@imap.disconnected?
|
32
|
-
rescue => e
|
33
|
+
rescue StandardError => e
|
33
34
|
# Ignore logout errors if connection is already closed
|
34
35
|
end
|
35
|
-
|
36
|
+
|
36
37
|
begin
|
37
38
|
@imap.disconnect if @imap && !@imap.disconnected?
|
38
|
-
rescue => e
|
39
|
+
rescue StandardError => e
|
39
40
|
# Ignore disconnect errors if connection is already closed
|
40
41
|
end
|
41
|
-
|
42
|
+
|
42
43
|
@imap = nil
|
43
44
|
end
|
44
45
|
|
45
46
|
def connected?
|
46
47
|
return false unless @imap
|
48
|
+
|
47
49
|
!@imap.disconnected?
|
48
50
|
end
|
49
51
|
|
52
|
+
# Get the list of available folders in the mailbox
|
53
|
+
def available_folders
|
54
|
+
raise ConnectionError, 'Not connected' unless connected?
|
55
|
+
|
56
|
+
@imap.list('', '*').map(&:name)
|
57
|
+
end
|
58
|
+
|
59
|
+
def select_folder(folder)
|
60
|
+
raise ConnectionError, 'Not connected' unless connected?
|
61
|
+
|
62
|
+
# Check if the folder exists
|
63
|
+
raise FolderError, "Folder '#{folder}' does not exist" unless available_folders.include?(folder)
|
64
|
+
|
65
|
+
@imap.select(folder)
|
66
|
+
@current_folder = folder
|
67
|
+
rescue Net::IMAP::Error => e
|
68
|
+
raise FolderError, "Failed to select folder '#{folder}': #{e.message}"
|
69
|
+
end
|
70
|
+
|
71
|
+
def select_inbox
|
72
|
+
select_folder('INBOX')
|
73
|
+
@current_folder = 'INBOX'
|
74
|
+
rescue FolderError => e
|
75
|
+
raise FolderError, "Failed to select INBOX: #{e.message}"
|
76
|
+
end
|
77
|
+
|
50
78
|
# Get all messages or a subset
|
51
79
|
def messages(limit: nil, reverse: true)
|
52
|
-
raise ConnectionError,
|
80
|
+
raise ConnectionError, 'Not connected' unless connected?
|
81
|
+
|
82
|
+
# Use search to get available sequence numbers (safer approach)
|
83
|
+
begin
|
84
|
+
sequence_numbers = @imap.search(['ALL'])
|
85
|
+
rescue Net::IMAP::Error => e
|
86
|
+
raise ConnectionError, "Failed to search messages: #{e.message}"
|
87
|
+
end
|
53
88
|
|
54
|
-
|
55
|
-
|
56
|
-
|
89
|
+
return [] if sequence_numbers.empty?
|
90
|
+
|
91
|
+
# Sort and limit the sequence numbers
|
92
|
+
if reverse
|
93
|
+
sequence_numbers = sequence_numbers.sort.reverse
|
94
|
+
else
|
95
|
+
sequence_numbers = sequence_numbers.sort
|
96
|
+
end
|
57
97
|
|
58
|
-
|
98
|
+
# Apply limit if specified
|
99
|
+
sequence_numbers = sequence_numbers.first(limit) if limit
|
100
|
+
|
101
|
+
fetch_messages_by_sequence(sequence_numbers)
|
59
102
|
end
|
60
103
|
|
61
104
|
# Get a specific message by UID
|
62
105
|
def message(uid)
|
63
|
-
raise ConnectionError,
|
64
|
-
|
65
|
-
fetch_data = @imap.uid_fetch(uid, [
|
106
|
+
raise ConnectionError, 'Not connected' unless connected?
|
107
|
+
|
108
|
+
fetch_data = @imap.uid_fetch(uid, %w[UID ENVELOPE BODYSTRUCTURE])
|
66
109
|
return nil if fetch_data.nil? || fetch_data.empty?
|
67
|
-
|
110
|
+
|
68
111
|
Message.new(self, fetch_data.first)
|
69
112
|
end
|
70
113
|
|
71
|
-
|
72
|
-
|
73
|
-
|
114
|
+
|
115
|
+
# Internal method to fetch messages by sequence number
|
116
|
+
def fetch_messages_by_sequence(sequence_numbers)
|
117
|
+
return [] if sequence_numbers.empty?
|
118
|
+
|
119
|
+
# Fetch messages in batches to avoid memory issues
|
120
|
+
batch_size = 50
|
121
|
+
all_messages = []
|
122
|
+
|
123
|
+
sequence_numbers.each_slice(batch_size) do |seq_batch|
|
124
|
+
fetch_data = @imap.fetch(seq_batch, %w[UID ENVELOPE BODYSTRUCTURE])
|
125
|
+
next if fetch_data.nil?
|
126
|
+
|
127
|
+
batch_messages = fetch_data.map { |data| Message.new(self, data) }
|
128
|
+
all_messages.concat(batch_messages)
|
129
|
+
end
|
130
|
+
|
131
|
+
all_messages
|
74
132
|
end
|
75
133
|
|
76
134
|
# Internal method to fetch message body parts
|
@@ -81,20 +139,10 @@ module PecRuby
|
|
81
139
|
private
|
82
140
|
|
83
141
|
def authenticate
|
84
|
-
@imap.authenticate(
|
142
|
+
@imap.authenticate('PLAIN', @username, @password)
|
85
143
|
rescue Net::IMAP::Error => e
|
86
144
|
raise AuthenticationError, "Authentication failed: #{e.message}"
|
87
145
|
end
|
88
146
|
|
89
|
-
def select_inbox
|
90
|
-
@imap.select('INBOX')
|
91
|
-
end
|
92
|
-
|
93
|
-
def fetch_messages(sequence_numbers)
|
94
|
-
return [] if sequence_numbers.empty?
|
95
|
-
|
96
|
-
messages_data = @imap.fetch(sequence_numbers, ["UID", "ENVELOPE", "BODYSTRUCTURE"])
|
97
|
-
messages_data.map { |msg_data| Message.new(self, msg_data) }
|
98
|
-
end
|
99
147
|
end
|
100
|
-
end
|
148
|
+
end
|
data/lib/pec_ruby/message.rb
CHANGED
@@ -15,8 +15,41 @@ module PecRuby
|
|
15
15
|
@postacert_extracted = false
|
16
16
|
end
|
17
17
|
|
18
|
-
# Basic envelope information
|
18
|
+
# Basic envelope information - now points to postacert.eml when available
|
19
19
|
def subject
|
20
|
+
if has_postacert?
|
21
|
+
postacert_message&.subject
|
22
|
+
else
|
23
|
+
original_subject
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def from
|
28
|
+
if has_postacert?
|
29
|
+
postacert_message&.from&.first
|
30
|
+
else
|
31
|
+
original_from
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def to
|
36
|
+
if has_postacert?
|
37
|
+
postacert_message&.to || []
|
38
|
+
else
|
39
|
+
original_to
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def date
|
44
|
+
if has_postacert?
|
45
|
+
postacert_message&.date
|
46
|
+
else
|
47
|
+
original_date
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
# Original message envelope information (the outer PEC container)
|
52
|
+
def original_subject
|
20
53
|
return nil unless @envelope.subject
|
21
54
|
|
22
55
|
decoded = Mail::Encodings.value_decode(@envelope.subject)
|
@@ -24,20 +57,20 @@ module PecRuby
|
|
24
57
|
decoded.strip
|
25
58
|
end
|
26
59
|
|
27
|
-
def
|
60
|
+
def original_from
|
28
61
|
return nil unless @envelope.from&.first
|
29
62
|
|
30
63
|
from_addr = @envelope.from.first
|
31
64
|
extract_real_sender(from_addr)
|
32
65
|
end
|
33
66
|
|
34
|
-
def
|
67
|
+
def original_to
|
35
68
|
return [] unless @envelope.to
|
36
69
|
|
37
70
|
@envelope.to.map { |addr| "#{addr.mailbox}@#{addr.host}" }
|
38
71
|
end
|
39
72
|
|
40
|
-
def
|
73
|
+
def original_date
|
41
74
|
@envelope.date ? Time.parse(@envelope.date.to_s) : nil
|
42
75
|
end
|
43
76
|
|
@@ -69,28 +102,8 @@ module PecRuby
|
|
69
102
|
@postacert_mail
|
70
103
|
end
|
71
104
|
|
72
|
-
# Get
|
73
|
-
def
|
74
|
-
postacert_message&.subject
|
75
|
-
end
|
76
|
-
|
77
|
-
# Get original message sender
|
78
|
-
def original_from
|
79
|
-
postacert_message&.from&.first
|
80
|
-
end
|
81
|
-
|
82
|
-
# Get original message recipients
|
83
|
-
def original_to
|
84
|
-
postacert_message&.to || []
|
85
|
-
end
|
86
|
-
|
87
|
-
# Get original message date
|
88
|
-
def original_date
|
89
|
-
postacert_message&.date
|
90
|
-
end
|
91
|
-
|
92
|
-
# Get original message body with format information
|
93
|
-
def original_body
|
105
|
+
# Get postacert message body with format information
|
106
|
+
def postacert_body
|
94
107
|
mail = postacert_message
|
95
108
|
return nil unless mail
|
96
109
|
|
@@ -116,8 +129,8 @@ module PecRuby
|
|
116
129
|
}
|
117
130
|
end
|
118
131
|
|
119
|
-
# Get
|
120
|
-
def
|
132
|
+
# Get postacert message body as plain text only
|
133
|
+
def postacert_body_text
|
121
134
|
mail = postacert_message
|
122
135
|
return nil unless mail
|
123
136
|
|
@@ -132,8 +145,8 @@ module PecRuby
|
|
132
145
|
raw_body.dup.force_encoding(charset).encode("UTF-8")
|
133
146
|
end
|
134
147
|
|
135
|
-
# Get
|
136
|
-
def
|
148
|
+
# Get postacert message body as HTML only
|
149
|
+
def postacert_body_html
|
137
150
|
mail = postacert_message
|
138
151
|
return nil unless mail
|
139
152
|
|
@@ -148,13 +161,40 @@ module PecRuby
|
|
148
161
|
raw_body.dup.force_encoding(charset).encode("UTF-8")
|
149
162
|
end
|
150
163
|
|
151
|
-
# Get
|
152
|
-
def
|
153
|
-
|
164
|
+
# Get message body - preferring postacert.eml if available, otherwise direct message
|
165
|
+
def raw_body
|
166
|
+
if has_postacert?
|
167
|
+
postacert_body
|
168
|
+
else
|
169
|
+
direct_message_body
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
# Get message body as plain text - preferring postacert.eml if available, otherwise direct message
|
174
|
+
def raw_body_text
|
175
|
+
if has_postacert?
|
176
|
+
postacert_body_text
|
177
|
+
else
|
178
|
+
direct_message_body_text
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
# Get message body as HTML - preferring postacert.eml if available, otherwise direct message
|
183
|
+
def raw_body_html
|
184
|
+
if has_postacert?
|
185
|
+
postacert_body_html
|
186
|
+
else
|
187
|
+
direct_message_body_html
|
188
|
+
end
|
189
|
+
end
|
190
|
+
|
191
|
+
# Get postacert message attachments (with memoization)
|
192
|
+
def postacert_attachments
|
193
|
+
@postacert_attachments ||= begin
|
154
194
|
mail = postacert_message
|
155
195
|
attachments = []
|
156
196
|
|
157
|
-
# Add attachments from the
|
197
|
+
# Add attachments from the postacert message
|
158
198
|
if mail&.attachments
|
159
199
|
attachments += mail.attachments.map { |att| Attachment.new(att) }
|
160
200
|
end
|
@@ -167,14 +207,53 @@ module PecRuby
|
|
167
207
|
end
|
168
208
|
end
|
169
209
|
|
170
|
-
# Get
|
210
|
+
# Get postacert message attachments that are NOT postacert.eml files
|
211
|
+
def postacert_regular_attachments
|
212
|
+
postacert_attachments.reject(&:postacert?)
|
213
|
+
end
|
214
|
+
|
215
|
+
# Get attachments - preferring postacert.eml if available, otherwise direct message
|
216
|
+
def attachments
|
217
|
+
if has_postacert?
|
218
|
+
postacert_attachments
|
219
|
+
else
|
220
|
+
[] # Direct messages typically don't have attachments via IMAP
|
221
|
+
end
|
222
|
+
end
|
223
|
+
|
224
|
+
# Get regular attachments (non-postacert.eml)
|
225
|
+
def regular_attachments
|
226
|
+
if has_postacert?
|
227
|
+
postacert_regular_attachments
|
228
|
+
else
|
229
|
+
[] # Direct messages typically don't have attachments via IMAP
|
230
|
+
end
|
231
|
+
end
|
232
|
+
|
233
|
+
# Legacy methods for backward compatibility
|
234
|
+
def original_attachments
|
235
|
+
postacert_attachments
|
236
|
+
end
|
237
|
+
|
171
238
|
def original_regular_attachments
|
172
|
-
|
239
|
+
postacert_regular_attachments
|
173
240
|
end
|
174
241
|
|
175
|
-
|
242
|
+
def original_body
|
243
|
+
postacert_body
|
244
|
+
end
|
245
|
+
|
246
|
+
def original_body_text
|
247
|
+
postacert_body_text
|
248
|
+
end
|
249
|
+
|
250
|
+
def original_body_html
|
251
|
+
postacert_body_html
|
252
|
+
end
|
253
|
+
|
254
|
+
# Get nested postacert.eml files from postacert message attachments
|
176
255
|
def nested_postacerts
|
177
|
-
@nested_postacerts ||=
|
256
|
+
@nested_postacerts ||= postacert_attachments.select(&:postacert?)
|
178
257
|
end
|
179
258
|
|
180
259
|
# Check if original message has nested postacert.eml files
|
@@ -329,5 +408,76 @@ module PecRuby
|
|
329
408
|
def extract_text_part(mail, preferred_type = "text/plain")
|
330
409
|
NestedPostacertMessage.new(mail).send(:extract_text_part, mail, preferred_type)
|
331
410
|
end
|
411
|
+
|
412
|
+
private
|
413
|
+
|
414
|
+
# Get the direct message body (without postacert.eml)
|
415
|
+
def direct_message_body
|
416
|
+
mail = direct_message_mail
|
417
|
+
return nil unless mail
|
418
|
+
|
419
|
+
text_part = extract_text_part(mail, "text/plain")
|
420
|
+
html_part = extract_text_part(mail, "text/html")
|
421
|
+
|
422
|
+
# Prefer text/plain, but return HTML if that's all we have
|
423
|
+
selected_part = text_part || html_part
|
424
|
+
|
425
|
+
return nil unless selected_part
|
426
|
+
|
427
|
+
raw_body = selected_part.body.decoded
|
428
|
+
charset = selected_part.charset ||
|
429
|
+
selected_part.content_type_parameters&.[]("charset") ||
|
430
|
+
"UTF-8"
|
431
|
+
|
432
|
+
content = raw_body.dup.force_encoding(charset).encode("UTF-8")
|
433
|
+
|
434
|
+
{
|
435
|
+
content: content,
|
436
|
+
content_type: selected_part.mime_type,
|
437
|
+
charset: charset
|
438
|
+
}
|
439
|
+
end
|
440
|
+
|
441
|
+
# Get the direct message body as plain text
|
442
|
+
def direct_message_body_text
|
443
|
+
mail = direct_message_mail
|
444
|
+
return nil unless mail
|
445
|
+
|
446
|
+
text_part = extract_text_part(mail, "text/plain")
|
447
|
+
return nil unless text_part
|
448
|
+
|
449
|
+
raw_body = text_part.body.decoded
|
450
|
+
charset = text_part.charset ||
|
451
|
+
text_part.content_type_parameters&.[]("charset") ||
|
452
|
+
"UTF-8"
|
453
|
+
|
454
|
+
raw_body.dup.force_encoding(charset).encode("UTF-8")
|
455
|
+
end
|
456
|
+
|
457
|
+
# Get the direct message body as HTML
|
458
|
+
def direct_message_body_html
|
459
|
+
mail = direct_message_mail
|
460
|
+
return nil unless mail
|
461
|
+
|
462
|
+
html_part = extract_text_part(mail, "text/html")
|
463
|
+
return nil unless html_part
|
464
|
+
|
465
|
+
raw_body = html_part.body.decoded
|
466
|
+
charset = html_part.charset ||
|
467
|
+
html_part.content_type_parameters&.[]("charset") ||
|
468
|
+
"UTF-8"
|
469
|
+
|
470
|
+
raw_body.dup.force_encoding(charset).encode("UTF-8")
|
471
|
+
end
|
472
|
+
|
473
|
+
# Get the direct message parsed as Mail object
|
474
|
+
def direct_message_mail
|
475
|
+
@direct_message_mail ||= begin
|
476
|
+
raw_data = @client.fetch_body_part(@uid, "")
|
477
|
+
Mail.read_from_string(raw_data)
|
478
|
+
rescue => e
|
479
|
+
raise Error, "Failed to extract direct message: #{e.message}"
|
480
|
+
end
|
481
|
+
end
|
332
482
|
end
|
333
483
|
end
|
data/lib/pec_ruby/version.rb
CHANGED
data/lib/pec_ruby.rb
CHANGED
@@ -1,13 +1,13 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require_relative
|
4
|
-
require_relative
|
5
|
-
require_relative
|
6
|
-
require_relative
|
3
|
+
require_relative 'pec_ruby/version'
|
4
|
+
require_relative 'pec_ruby/client'
|
5
|
+
require_relative 'pec_ruby/message'
|
6
|
+
require_relative 'pec_ruby/attachment'
|
7
7
|
|
8
8
|
# CLI is optional - only load if dependencies are available
|
9
9
|
begin
|
10
|
-
require_relative
|
10
|
+
require_relative 'pec_ruby/cli'
|
11
11
|
rescue LoadError
|
12
12
|
# CLI dependencies not available - skip CLI functionality
|
13
13
|
end
|
@@ -18,4 +18,5 @@ module PecRuby
|
|
18
18
|
class AuthenticationError < Error; end
|
19
19
|
class MessageNotFoundError < Error; end
|
20
20
|
class PostacertNotFoundError < Error; end
|
21
|
-
end
|
21
|
+
class FolderError < Error; end
|
22
|
+
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: pec_ruby
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.2.
|
4
|
+
version: 0.2.3
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- EMG
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2025-07-
|
11
|
+
date: 2025-07-14 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: mail
|
@@ -38,6 +38,20 @@ dependencies:
|
|
38
38
|
- - "~>"
|
39
39
|
- !ruby/object:Gem::Version
|
40
40
|
version: '0.3'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: tty-screen
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0.8'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0.8'
|
41
55
|
- !ruby/object:Gem::Dependency
|
42
56
|
name: rspec
|
43
57
|
requirement: !ruby/object:Gem::Requirement
|