libis-format 1.3.7.2 → 1.3.8.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 45a69e173c161cfa97afd3ad4b2cc9193cc883c5acaca3708466ffec56c55d8c
4
- data.tar.gz: fad16f856808e6b02eaa31a4b02f2abba250b59b3baa821c5e1a27c17e842d98
3
+ metadata.gz: ec695a5009a2c131983af21f4c773037731907280eb229982eeacc8f7c72bf6b
4
+ data.tar.gz: 48139c18c6b66ddc9b25941dcbecee30294c3d7124d889dd74fae34b2a176c74
5
5
  SHA512:
6
- metadata.gz: f9109cc481ff67637fac70eaa5e55ea107fea452e3ae1636d54e628b234e70d5531aafc6be480205bbbb7d41b7f1f1f0bf2ab467f7db816b201c59b6b733b3d9
7
- data.tar.gz: ddb206f9fa5dc4bea55859fa0660f725eab006e01fe0d039f158ca45d430bf41b3bb18591520052a996743716b400a8221a0ce73b4a2affac42ad79cc6980333
6
+ metadata.gz: b9f232072ceed43e950d51869478c18e03cb79e157749a34e7580330a9e379f3008bd8f963461ea81261bdaf95b65d779b1d3c0efd1da86ef2d1fc729316cb43
7
+ data.tar.gz: cb40a76524a3854692b81baace242bf96a22c92bcde21192dffdaf6bd5afc187c7b4c7190866e0f0b9c17462d4392acec8b12e07fc6acaaf76192b171c37cb05
@@ -3,6 +3,7 @@
3
3
  require_relative 'base'
4
4
 
5
5
  require 'libis/format/tool/msg_to_pdf'
6
+ require 'libis/format/tool/eml_to_pdf'
6
7
  require 'libis/format/type_database'
7
8
  require 'rexml/document'
8
9
 
@@ -10,7 +11,6 @@ module Libis
10
11
  module Format
11
12
  module Converter
12
13
  class EmailConverter < Libis::Format::Converter::Base
13
-
14
14
  def self.input_types
15
15
  %i[MSG EML]
16
16
  end
@@ -28,7 +28,22 @@ module Libis
28
28
  def convert(source, target, format, opts = {})
29
29
  super
30
30
 
31
- Format::Tool::MsgToPdf.run(source, target)
31
+ tool = tool_for_format(opts, source)
32
+ tool.run(source, target)
33
+ rescue StandardError => e
34
+ { command: { status: -1 }, errors: [{ error: e.message, error_class: e.class, error_trace: e.backtrace }] }
35
+ end
36
+
37
+ private
38
+
39
+ def tool_for_format(opts, source)
40
+ if opts[:source_format] == :MSG || File.extname(source).casecmp('.msg').zero?
41
+ Format::Tool::MsgToPdf
42
+ elsif opts[:source_format] == :EML || File.extname(source).casecmp('.eml').zero?
43
+ Format::Tool::EmlToPdf
44
+ else
45
+ raise "Unsupported file extension #{File.extname(source)}"
46
+ end
32
47
  end
33
48
  end
34
49
  end
@@ -0,0 +1,168 @@
1
+ # rubocop:disable Style/Documentation, Metrics/*
2
+ # frozen_string_literal: true
3
+
4
+ require_relative 'mail_to_pdf'
5
+
6
+ require 'mail'
7
+ require 'word_wrap'
8
+
9
+ module Libis
10
+ module Format
11
+ module Tool
12
+ class EmlToPdf < MailToPdf
13
+ protected
14
+
15
+ def open_email(source)
16
+ eml = File.read(source)
17
+ begin
18
+ msg = Mail.new(eml)
19
+ rescue StandardError => e
20
+ eml.force_encoding('ASCII-8BIT')
21
+ msg = Mail.new(eml)
22
+ @warnings << "Failed to parse message with default encoding, forced ASCII-8BIT: #{e.message}"
23
+ end
24
+ msg
25
+ rescue StandardError => e
26
+ raise "Failed to open message: #{e.message}"
27
+ end
28
+
29
+ def close_email(_msg)
30
+ true
31
+ end
32
+
33
+ def get_body_html(msg)
34
+ # Get the encoding
35
+
36
+ if !msg.multipart?
37
+ HTML_WRAPPER_TEMPLATE % WordWrap.ww(msg.decoded, 120, false)
38
+
39
+ elsif msg.html_part
40
+ body = msg.html_part.body.decoded
41
+
42
+ # if the encoding is not UTF-8, then the HTML may contain metadata that specifies the encoding
43
+ # the browser will use that metadata to render the HTML correctly, so we should not force it to UTF-8
44
+ encoding = body.scan(/<\?xml\s[^?>]*encoding="([^"]*)"[^?>]*\?>/).flatten.first
45
+ encoding ||= body.scan(/<meta\s+[^>]*charset=["']?([^"'>\s]+)["']?[^>]*>/).flatten.first
46
+ encoding ||= msg.html_part.charset
47
+ encoding ||= msg.content_type_parameters['charset'] || msg.charset || 'UTF-8'
48
+
49
+ body.force_encoding(encoding) unless encoding.casecmp(body.encoding.name).zero?
50
+
51
+ body
52
+ elsif msg.text_part
53
+ HTML_WRAPPER_TEMPLATE % msg.text_part.decoded
54
+
55
+ else
56
+ HTML_WRAPPER_TEMPLATE % ''
57
+
58
+ end
59
+ end
60
+
61
+ def get_subject(msg)
62
+ msg.subject || 'No Subject'
63
+ end
64
+
65
+ def get_headers(msg)
66
+ headers = {}
67
+ html = ''
68
+
69
+ field_list = msg.header_fields
70
+
71
+ %w[From To Cc Subject Date].each do |key|
72
+ value = find_hdr(field_list, key)
73
+ next unless value
74
+
75
+ if value.is_a? Time
76
+ begin
77
+ headers[key.downcase.to_sym] = value.iso8601
78
+ html += hdr_html(key, value.rfc2822)
79
+ rescue StandardError => e
80
+ logger.warn "Failed to parse date header '#{value}': #{e.message}"
81
+ end
82
+ else
83
+ headers[key.downcase.to_sym] = value
84
+ html += hdr_html(key, value)
85
+ end
86
+ end
87
+
88
+ [headers, html]
89
+ end
90
+
91
+ def get_inline_attachment_data(attachments, cid)
92
+ attachments.each do |attachment|
93
+ next unless attachment.has_content_id?
94
+ next unless attachment.cid == cid
95
+
96
+ begin
97
+ attachment.data.rewind
98
+ rescue NoMethodError
99
+ # do nothing, attachment.data is not a stream
100
+ end
101
+ return {
102
+ mime_type: attachment.mime_type,
103
+ base64: Base64.strict_encode64(attachment.read)
104
+ }
105
+ end
106
+ nil
107
+ end
108
+
109
+ def get_file_attachments(attachments, _used_files)
110
+ attachments.select do |attachment|
111
+ !attachment.inline? &&
112
+ !attachment.has_content_id? &&
113
+ attachment.mime_type != 'message/rfc822'
114
+ end
115
+ end
116
+
117
+ def get_mail_attachments(attachments)
118
+ attachments.select do |attachment|
119
+ !attachment.inline? &&
120
+ !attachment.has_content_id? &&
121
+ attachment.mime_type == 'message/rfc822'
122
+ end
123
+ end
124
+
125
+ def get_attachment_info(attachment)
126
+ if attachment.mime_type == 'message/rfc822'
127
+ sub_msg = Mail.new(attachment.body.to_s)
128
+ subject = sub_msg.subject.to_s.strip
129
+
130
+ {
131
+ embedded_msg: sub_msg,
132
+ filename: subject
133
+ }
134
+
135
+ elsif attachment.filename
136
+
137
+ {
138
+ data: attachment.decoded,
139
+ filename: attachment.filename
140
+ }
141
+
142
+ else
143
+ {
144
+ filename: attachment.content_id.to_s
145
+ }
146
+ end
147
+ end
148
+
149
+ private
150
+
151
+ def find_hdr(list, key)
152
+ hdr = list.find { |x| x.name.to_s =~ /^#{key}$/i }
153
+ return nil unless hdr
154
+
155
+ field = hdr.field
156
+ return hdr.value unless field
157
+
158
+ return field.decoded unless field.is_a? Mail::CommonDateField
159
+
160
+ return field.date_time.to_time.localtime if field.respond_to?(:date_time)
161
+
162
+ DateTime.parse(field.decoded).to_time.localtime
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
168
+ # rubocop:enable Style/Documentation, Metrics/*
@@ -0,0 +1,447 @@
1
+ # rubocop:disable Style/Documentation, Metrics/*
2
+ # frozen_string_literal: true
3
+
4
+ require 'base64'
5
+ require 'cgi'
6
+ require 'pdfkit'
7
+ require 'time'
8
+ require 'fileutils'
9
+ require 'pathname'
10
+ require 'libis/format/config'
11
+
12
+ module Libis
13
+ module Format
14
+ module Tool
15
+ HEADER_STYLE = <<~HTML
16
+ <style>
17
+ .header-table {
18
+ margin: 0 0 10px 0;
19
+ padding: 0;
20
+ font-family: Arial, Helvetica, sans-serif;
21
+ }
22
+ .header-table table {
23
+ width: 100%;
24
+ }
25
+ .header-name {
26
+ padding-right: 5px;
27
+ color: #9E9E9E;
28
+ text-align: right;
29
+ vertical-align: top;
30
+ font-size: 12px;
31
+ }
32
+ .header-value {
33
+ font-size: 12px;
34
+ width: 99%;
35
+ }
36
+ .header_fields {
37
+ background: white;
38
+ margin: 0;
39
+ border: 1px solid #DDD;
40
+ border-radius: 3px;
41
+ padding: 8px;
42
+ box-sizing: border-box;
43
+ }
44
+ </style>
45
+ HTML
46
+
47
+ HEADER_TABLE_TEMPLATE = <<~HTML
48
+ <div class="header-table">
49
+ <table class="header_fields">
50
+ <tbody>
51
+ %s
52
+ </tbody>
53
+ </table>
54
+ </div>
55
+ HTML
56
+
57
+ HEADER_FIELD_TEMPLATE = <<~HTML
58
+ <tr>
59
+ <td class="header-name">%s</td>
60
+ <td class="header-value">%s</td>
61
+ </tr>
62
+ HTML
63
+
64
+ HTML_WRAPPER_TEMPLATE = <<~HTML
65
+ <!DOCTYPE html>
66
+ <html>
67
+ <head>
68
+ <style>
69
+ body {
70
+ font-size: 12px;
71
+ }
72
+ </style>
73
+ <title>title</title>
74
+ </head>
75
+ <body>
76
+ <pre>
77
+ %s
78
+ </pre>
79
+ </body>
80
+ </html>
81
+ HTML
82
+
83
+ HTML_BODY_TEMPLATE = <<~HTML
84
+ <!DOCTYPE html>
85
+ <html>
86
+ <head>
87
+ <style>
88
+ body {
89
+ font-size: 12px;
90
+ }
91
+ </style>
92
+ <title>title</title>
93
+ </head>
94
+ <body>
95
+ %s
96
+ </body>
97
+ </html>
98
+ HTML
99
+
100
+ ATTACHMENT_STYLE = <<~HTML
101
+ <style>
102
+ .attachment-list {
103
+ border: 1px solid #DDD;
104
+ margin: 0 0 10px 0;
105
+ padding: 0;
106
+ font-family: Arial, Helvetica, sans-serif;
107
+ }
108
+ .attachment-list ul {
109
+ list-style: disclosure-closed;
110
+ }
111
+ .attachment-list li {
112
+ font-size: 12px;
113
+ padding-left: 1em;
114
+ }
115
+ </style>
116
+ HTML
117
+
118
+ ATTACHMENT_LIST_TEMPLATE = <<~HTML
119
+ <div class="attachment-list">
120
+ <ul>
121
+ %s
122
+ </ul>
123
+ </div>
124
+ HTML
125
+
126
+ HTML_DOCTYPE_TEMPLATE = '<!DOCTYPE html>%s'
127
+ ATTACHMENT_ITEM_TEMPLATE = '<li>%s</li>'
128
+
129
+ IMG_CID_PLAIN_REGEX = /\[cid:(.*?)\]/im
130
+ IMG_CID_HTML_REGEX = /cid:([^"]*)/im
131
+
132
+ class MailToPdf
133
+ include ::Libis::Tools::Logger
134
+
135
+ def self.installed?
136
+ File.exist?(Libis::Format::Config[:wkhtmltopdf])
137
+ end
138
+
139
+ def self.run(source, target, **options)
140
+ new.run source, target, **options
141
+ end
142
+
143
+ def run(source, target, **options)
144
+ # Preliminary checks
145
+ @warnings = []
146
+
147
+ # PDF creation options
148
+ @pdf_options = {
149
+ page_size: 'A4',
150
+ margin_top: '10mm',
151
+ margin_bottom: '10mm',
152
+ margin_left: '10mm',
153
+ margin_right: '10mm',
154
+ # image_quality: 100,
155
+ # viewport_size: '2480x3508',
156
+ dpi: 300
157
+ }.merge options
158
+
159
+ # Check if source file exists
160
+ raise "File #{source} does not exist" unless File.exist?(source)
161
+
162
+ # Open the email
163
+ email = open_email(source)
164
+
165
+ # Convert the email message to PDF
166
+ result = email_to_pdf(email, target, root_msg: true)
167
+
168
+ # Close email message
169
+ close_email(email)
170
+
171
+ result
172
+ end
173
+
174
+ protected
175
+
176
+ def email_to_pdf(msg, target, root_msg: false)
177
+ # Make sure the target directory exists
178
+ outdir = File.dirname(target)
179
+ FileUtils.mkdir_p(outdir)
180
+
181
+ # Process the message body
182
+ # ------------------------
183
+ body = get_body(msg)
184
+
185
+ # Process headers
186
+ # ---------------
187
+ headers, headers_html = get_headers(msg)
188
+
189
+ # Add header section to the HTML body
190
+ body = add_headers_to_body(body, headers_html)
191
+
192
+ # Embed inline images
193
+ # -------------------
194
+ attachments = msg.attachments
195
+ used_files = embed_inline_attachments(body, attachments)
196
+
197
+ # Save other attachments
198
+ # ----------------------
199
+ attachments_dir = "#{target}.attachments"
200
+
201
+ files = save_attachments(attachments, attachments_dir, used_files)
202
+
203
+ # Add attachment section to the HTML body
204
+ body = add_attachments_to_body(body, files, attachments_dir)
205
+
206
+ # Save the HTML body as a .html file next to the PDF for debugging purposes
207
+ File.open("#{target}.html", 'wb') { |f| f.write(body) }
208
+
209
+ # Create PDF
210
+ # ----------
211
+ write_target_file(body, get_subject(msg), target)
212
+
213
+ files = [target] + files if File.exist?(target)
214
+
215
+ if root_msg
216
+ p = Pathname(File.dirname(files.first))
217
+ files.drop(1).each do |f|
218
+ (headers[:attachments] ||= []) << Pathname.new(f).relative_path_from(p).to_s
219
+ end
220
+ end
221
+
222
+ {
223
+ command: { status: 0 },
224
+ files:,
225
+ headers:,
226
+ warnings: @warnings
227
+ }
228
+ rescue StandardError => e
229
+ raise unless root_msg
230
+
231
+ close_email(msg) if msg
232
+ {
233
+ command: { status: -1 },
234
+ files: [],
235
+ headers: {},
236
+ errors: [
237
+ {
238
+ error: e.message,
239
+ error_class: e.class.name,
240
+ error_trace: e.backtrace
241
+ }
242
+ ],
243
+ warnings: @warnings
244
+ }
245
+ end
246
+
247
+ def get_body(msg)
248
+ body = get_body_html(msg)
249
+
250
+ body = HTML_BODY_TEMPLATE % body unless /<body[^>]*>/i.match?(body)
251
+ body = HTML_DOCTYPE_TEMPLATE % body unless /<!DOCTYPE html/i.match?(body)
252
+ body.sub!(%r{<title>title</title>}, "<title>#{get_subject(msg)}</title>")
253
+
254
+ body
255
+ end
256
+
257
+ def add_headers_to_body(body, headers_html)
258
+ encoding = body.encoding
259
+ return body if headers_html.empty?
260
+
261
+ b = body.downcase
262
+
263
+ # Insert header block styles
264
+ if b.include?('</head>')
265
+ # if head exists, append the style block
266
+ body.gsub!(%r{</head>}i, "#{HEADER_STYLE}</head>")
267
+ elsif b.include?('<head/>')
268
+ # empty head, replace with the style block
269
+ body.gsub!(%r{<head/>}i, "<head>#{HEADER_STYLE}</head>")
270
+ else
271
+ # otherwise insert a head section before the body tag
272
+ body.gsub!(/<body/i, "<head>#{HEADER_STYLE}</head><body")
273
+ end
274
+ # Add the headers html table as first element in the body section
275
+ body.gsub!(/<body[^>]*>/i) { |m| "#{m}#{HEADER_TABLE_TEMPLATE % headers_html.encode(encoding)}" }
276
+ body
277
+ end
278
+
279
+ def hdr_html(key, value)
280
+ if key.is_a?(String) && value.is_a?(String) && !value.empty?
281
+ return format(HEADER_FIELD_TEMPLATE, key,
282
+ CGI.escapeHTML(value))
283
+ end
284
+
285
+ ''
286
+ end
287
+
288
+ def embed_inline_attachments(body, attachments)
289
+ used_files = []
290
+
291
+ # First process plaintext cid entries
292
+ body.gsub!(IMG_CID_PLAIN_REGEX) do |_match|
293
+ data = get_inline_attachment_data(attachments, ::Regexp.last_match(1))
294
+ if data
295
+ used_files << ::Regexp.last_match(1)
296
+ "<img src=\"data:#{data[:mime_type]};base64,#{data[:base64]}\"/>"
297
+ else
298
+ '<img src=""/>'
299
+ end
300
+ end
301
+
302
+ # Then process HTML img tags with CID entries
303
+ body.gsub!(IMG_CID_HTML_REGEX) do |_match|
304
+ data = get_inline_attachment_data(attachments, ::Regexp.last_match(1))
305
+ if data
306
+ used_files << ::Regexp.last_match(1)
307
+ "data:#{data[:mime_type]};base64,#{data[:base64]}"
308
+ else
309
+ ''
310
+ end
311
+ end
312
+
313
+ used_files
314
+ end
315
+
316
+ def save_attachments(attachments, outdir, used_files)
317
+ files = []
318
+
319
+ digits = ((attachments.count + 1) / 10) + 1
320
+ i = 1
321
+
322
+ get_attachments(attachments, used_files).each do |attachment|
323
+ prefix = "#{format('%0*d', digits, i)}-"
324
+
325
+ info = get_attachment_info(attachment)
326
+
327
+ if info[:embedded_msg]
328
+ sub_msg = info[:embedded_msg]
329
+ file = File.join(outdir, "#{prefix}#{info[:filename].tr('/', '_')}.msg.pdf")
330
+
331
+ result = email_to_pdf(sub_msg, file, root_msg: false)
332
+
333
+ if (e = result[:error])
334
+ raise e
335
+ end
336
+
337
+ files += result[:files]
338
+ elsif info[:data]
339
+ file = File.join(outdir, "#{prefix}#{info[:filename].tr('/', '_')}")
340
+ FileUtils.mkdir_p(File.dirname(file))
341
+ File.open(file, 'wb') { |f| f.write(info[:data]) }
342
+ files << file
343
+ else
344
+ @warnings << "Attachment #{info[:filename]} cannot be extracted"
345
+ next
346
+ end
347
+
348
+ i += 1
349
+ end
350
+ files
351
+ end
352
+
353
+ def add_attachments_to_body(body, files, attachments_dir)
354
+ return body if files.empty?
355
+
356
+ b = body.downcase
357
+
358
+ # Insert attachment block styles
359
+ if b.include?('</head>')
360
+ # if head exists, append the style block
361
+ body.gsub!(%r{</head>}i, "#{ATTACHMENT_STYLE}</head>")
362
+ elsif b.include?('<head/>')
363
+ # empty head, replace with the style block
364
+ body.gsub!(%r{<head/>}i, "<head>#{ATTACHMENT_STYLE}</head>")
365
+ else
366
+ # otherwise insert a head section before the body tag
367
+ body.gsub!(/<body/i, "<head>#{ATTACHMENT_STYLE}</head><body")
368
+ end
369
+
370
+ # Filter files to only include those that are in the attachments directory
371
+ # and map them to relative paths from the attachments directory
372
+ items = files.filter_map do |f|
373
+ Pathname.new(f).relative_path_from(Pathname.new(attachments_dir)).to_s if File.dirname(f) == attachments_dir
374
+ end
375
+
376
+ # Create the attachment item HTML
377
+ items = items.map do |f|
378
+ format(ATTACHMENT_ITEM_TEMPLATE, f, File.basename(f))
379
+ end.join("\n")
380
+
381
+ # Create the attachment list HTML
382
+ attachments_html = ATTACHMENT_LIST_TEMPLATE % items
383
+
384
+ # make sure the attachments_html is encoded in the same encoding as the body
385
+ attachments_html = attachments_html.encode(body.encoding)
386
+
387
+ # Add the attachments html list after the headers
388
+ # if there are no headers, then add it at the beginning of the body section
389
+ body.sub!(%r{<div class="header-table">.*?</div>}im) { |m| "#{m}#{attachments_html}" } ||
390
+ body.gsub!(/<body[^>]*>/i) { |m| "#{m}#{attachments_html}" }
391
+
392
+ body
393
+ end
394
+
395
+ def get_attachments(attachments, used_files)
396
+ get_file_attachments(attachments, used_files) + get_mail_attachments(attachments)
397
+ end
398
+
399
+ def write_target_file(body, title, target)
400
+ kit = PDFKit.new(body, title: title || 'message', **@pdf_options)
401
+ pdf = kit.to_pdf
402
+ File.open(target, 'wb') { |f| f.write(pdf) }
403
+ end
404
+
405
+ # ---------------------------------------
406
+ # Methods to be implemented by subclasses
407
+ # ---------------------------------------
408
+ def open_email(_source)
409
+ raise NotImplementedError, 'Subclasses must implement the open_email method'
410
+ end
411
+
412
+ def close_email(_email)
413
+ raise NotImplementedError, 'Subclasses must implement the close_email method'
414
+ end
415
+
416
+ def get_body_html(_msg)
417
+ raise NotImplementedError, 'Subclasses must implement the get_body_html method'
418
+ end
419
+
420
+ def get_subject(_msg)
421
+ raise NotImplementedError, 'Subclasses must implement the get_subject method'
422
+ end
423
+
424
+ def get_headers(_msg)
425
+ raise NotImplementedError, 'Subclasses must implement the get_headers method'
426
+ end
427
+
428
+ def get_inline_attachment_data(_attachments, _cid)
429
+ raise NotImplementedError, 'Subclasses must implement the get_inline_attachment_data method'
430
+ end
431
+
432
+ def get_file_attachments(_attachments, _used_files)
433
+ raise NotImplementedError, 'Subclasses must implement the get_file_attachments method'
434
+ end
435
+
436
+ def get_mail_attachments(_attachments)
437
+ raise NotImplementedError, 'Subclasses must implement the get_mail_attachments method'
438
+ end
439
+
440
+ def get_attachment_info(_attachment)
441
+ raise NotImplementedError, 'Subclasses must implement the get_attachment_info method'
442
+ end
443
+ end
444
+ end
445
+ end
446
+ end
447
+ # rubocop:enable Style/Documentation, Metrics/*
@@ -1,270 +1,154 @@
1
+ # rubocop:disable Style/Documentation, Metrics/*
1
2
  # frozen_string_literal: true
2
3
 
3
- require 'mapi/msg'
4
- require 'rfc_2047'
5
- require 'cgi'
6
- require 'pdfkit'
7
- require 'time'
8
- require 'fileutils'
9
- require 'pathname'
10
- require 'libis/format/config'
4
+ require_relative 'mail_to_pdf'
5
+
6
+ require 'msg_extractor'
7
+ require 'word_wrap'
11
8
 
12
9
  module Libis
13
10
  module Format
14
11
  module Tool
15
- class MsgToPdf
16
- include ::Libis::Tools::Logger
12
+ class MsgToPdf < MailToPdf
13
+ protected
17
14
 
18
- HEADER_STYLE = '<style>.header-table {margin: 0 0 20 0;padding: 0;font-family: Arial, Helvetica, sans-serif;}.header-name {padding-right: 5px;color: #9E9E9E;text-align: right;vertical-align: top;font-size: 12px;}.header-value {font-size: 12px;}#header_fields {#background: white;#margin: 0;#border: 1px solid #DDD;#border-radius: 3px;#padding: 8px;#width: 100%%;#box-sizing: border-box;#}</style><script type="text/javascript">function timer() {try {parent.postMessage(Math.max(document.body.offsetHeight, document.body.scrollHeight), \'*\');} catch (r) {}setTimeout(timer, 10);};timer();</script>' # rubocop:disable Layout/LineLength
19
- HEADER_TABLE_TEMPLATE = '<div class="header-table"><table id="header_fields"><tbody>%s</tbody></table></div>'
20
- HEADER_FIELD_TEMPLATE = '<tr><td class="header-name">%s</td><td class="header-value">%s</td></tr>'
21
- HTML_WRAPPER_TEMPLATE = '<!DOCTYPE html><html><head><style>body {font-size: 0.5cm;}</style><title>title</title></head><body>%s</body></html>' # rubocop:disable Layout/LineLength
15
+ def open_email(source)
16
+ msg = nil
22
17
 
23
- IMG_CID_PLAIN_REGEX = /\[cid:(.*?)\]/m
24
- IMG_CID_HTML_REGEX = /cid:([^"]*)/m
18
+ # Open the message
19
+ msg = MsgExtractor.open(source)
25
20
 
26
- def self.installed?
27
- File.exist?(Libis::Format::Config[:wkhtmltopdf])
28
- end
21
+ unless msg.is_a?(MsgExtractor::Message)
22
+ raise "File #{File.basename(source)} is not an Outlook message but a #{msg.class.name}"
23
+ end
29
24
 
30
- def self.run(source, target, **options)
31
- new.run source, target, **options
25
+ msg
26
+ rescue StandardError => e
27
+ raise "Failed to open message: #{e.message}"
32
28
  end
33
29
 
34
- def run(source, target, **options)
35
- # Preliminary checks
36
- # ------------------
37
-
38
- @warnings = []
39
-
40
- # Check if source file exists
41
- raise "File #{source} does not exist" unless File.exist?(source)
42
-
43
- # Retrieving the message
44
- # ----------------------
45
-
46
- # Open the message
47
- msg = Mapi::Msg.open(source)
48
-
49
- target_format = options.delete(:to_html) ? :HTML : :PDF
50
- result = msg_to_pdf(msg, target, target_format, options)
51
- msg.close
52
- result
30
+ def close_email(_msg)
31
+ true
53
32
  end
54
33
 
55
- def msg_to_pdf(msg, target, target_format, pdf_options, root_msg: true)
56
- # Make sure the target directory exists
57
- outdir = File.dirname(target)
58
- FileUtils.mkdir_p(outdir)
59
-
34
+ def get_body_html(msg)
60
35
  # Get the body of the message in HTML
61
- body = msg.properties.body_html
36
+ body = msg.html_body
62
37
 
63
38
  # Embed plain body in HTML as a fallback
64
- body ||= HTML_WRAPPER_TEMPLATE % msg.properties.body
65
-
66
- # Check and fix the character encoding
67
- begin
68
- # Try to encode into UTF-8
69
- body.encode!('UTF-8', universal_newline: true)
70
- rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
71
- begin
72
- # If it fails, the text may be in Windows' Latin1 (ISO-8859-1)
73
- body.force_encoding('ISO-8859-1').encode!('UTF-8', universal_newline: true)
74
- rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError => e
75
- # If that fails too, log a warning and replace the invalid/unknown with a ? character.
76
- @warnings << "#{e.class}: #{e.message}"
77
- body.encode!('UTF-8', universal_newline: true, invalid: :replace, undef: :replace)
78
- end
79
- end
39
+ body ||= HTML_WRAPPER_TEMPLATE % WordWrap.ww(msg.body, 120, false)
40
+
41
+ # Worst case, just create empty body
42
+ body ||= HTML_WRAPPER_TEMPLATE % ''
43
+
44
+ # # Check and fix the character encoding
45
+ # begin
46
+ # # Try to encode into UTF-8
47
+ # body.encode!('UTF-8', universal_newline: true)
48
+ # rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
49
+ # begin
50
+ # # If it fails, the text may be in Windows' Latin1 (ISO-8859-1)
51
+ # body.force_encoding('ISO-8859-1').encode!('UTF-8', universal_newline: true)
52
+ # rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError => e
53
+ # # If that fails too, log a warning and replace the invalid/unknown with a ? character.
54
+ # @warnings << "#{e.class}: #{e.message}"
55
+ # body.encode!('UTF-8', universal_newline: true, invalid: :replace, undef: :replace)
56
+ # end
57
+ # end
58
+
59
+ body
60
+ end
80
61
 
81
- # Process headers
82
- # ---------------
62
+ def get_subject(msg)
63
+ msg.subject || ''
64
+ end
65
+
66
+ def get_headers(msg)
83
67
  headers = {}
84
- hdr_html = ''
68
+ html = ''
85
69
 
86
70
  %w[From To Cc Subject Date].each do |key|
87
- value = find_hdr(msg.headers, key)
88
- if value
89
- headers[key.downcase.to_sym] = value
90
- hdr_html += hdr_html(key, value)
91
- end
92
- end
93
-
94
- [:date].each do |key|
95
- next unless headers[key]
96
-
97
- headers[key] = DateTime.parse(headers[key]).to_time.localtime.iso8601
98
- end
71
+ value = find_hdr(msg, key)
72
+ next unless value
99
73
 
100
- # Add header section to the HTML body
101
- unless hdr_html.empty?
102
- # Insert header block styles
103
- if body =~ %r{</head>}
104
- # if head exists, append the style block
105
- body.gsub!(%r{</head>}, "#{HEADER_STYLE}</head>")
106
- elsif body =~ %r{<head/>}
107
- # empty head, replace with the style block
108
- body.gsub!(%r{<head/>}, "<head>#{HEADER_STYLE}</head>")
74
+ if key.casecmp('Date').zero? && value.is_a?(Time)
75
+ headers[key.downcase.to_sym] = value.iso8601
76
+ html += hdr_html(key, value.rfc2822)
109
77
  else
110
- # otherwise insert a head section before the body tag
111
- body.gsub!(/<body/, "<head>#{HEADER_STYLE}</head><body")
78
+ headers[key.downcase.to_sym] = value
79
+ html += hdr_html(key, value)
112
80
  end
113
- # Add the headers html table as first element in the body section
114
- body.gsub!(/<body[^>]*>/) { |m| "#{m}#{HEADER_TABLE_TEMPLATE % hdr_html}" }
115
81
  end
116
82
 
117
- # Embed inline images
118
- # -------------------
119
- attachments = msg.attachments
120
- used_files = []
121
-
122
- # First process plaintext cid entries
123
- body.gsub!(IMG_CID_PLAIN_REGEX) do |_match|
124
- data = get_attachment_data(attachments, ::Regexp.last_match(1))
125
- if data
126
- used_files << ::Regexp.last_match(1)
127
- "<img src=\"data:#{data[:mime_type]};base64,#{data[:base64]}\"/>"
128
- else
129
- '<img src=""/>'
130
- end
131
- end
83
+ [headers, html]
84
+ end
132
85
 
133
- # Then process HTML img tags with CID entries
134
- body.gsub!(IMG_CID_HTML_REGEX) do |_match|
135
- data = get_attachment_data(attachments, ::Regexp.last_match(1))
136
- if data
137
- used_files << ::Regexp.last_match(1)
138
- "data:#{data[:mime_type]};base64,#{data[:base64]}"
139
- else
140
- ''
141
- end
142
- end
86
+ def get_inline_attachment_data(attachments, cid)
87
+ attachments.each do |attachment|
88
+ next unless attachment.content_id == cid
143
89
 
144
- # Create PDF
145
- # ----------
146
- files = []
147
-
148
- if target_format == :PDF
149
- # PDF creation options
150
- pdf_options = {
151
- page_size: 'A4',
152
- margin_top: '10mm',
153
- margin_bottom: '10mm',
154
- margin_left: '10mm',
155
- margin_right: '10mm',
156
- # image_quality: 100,
157
- # viewport_size: '2480x3508',
158
- dpi: 300
159
- }.merge pdf_options
160
-
161
- subject = find_hdr(msg.headers, 'Subject')
162
- kit = PDFKit.new(body, title: (subject || 'message'), **pdf_options)
163
- pdf = kit.to_pdf
164
- File.open(target, 'wb') { |f| f.write(pdf) }
165
- else
166
- File.open(target, 'wb') { |f| f.write(body) }
167
- end
168
- files << target if File.exist?(target)
169
-
170
- # Save attachments
171
- # ----------------
172
- outdir = File.join(outdir, "#{File.basename(target)}.attachments")
173
- digits = ((attachments.count + 1) / 10) + 1
174
- i = 1
175
- attachments.delete_if { |a| a.properties.attachment_hidden }.each do |a|
176
- prefix = "#{format('%0*d', digits, i)}-"
177
- if (sub_msg = a.instance_variable_get(:@embedded_msg))
178
- subject = a.properties[:display_name] || sub_msg.subject || ''
179
- file = File.join(outdir, "#{prefix}#{subject.gsub('/', '_')}.msg.#{target_format.to_s.downcase}")
180
- result = msg_to_pdf(sub_msg, file, target_format, pdf_options, root_msg: false)
181
- if (e = result[:error])
182
- raise e
183
- end
184
-
185
- files += result[:files]
186
- elsif a.filename
187
- next if used_files.include?(a.filename)
188
-
189
- file = File.join(outdir, "#{prefix}#{a.filename.gsub('/', '_')}")
190
- FileUtils.mkdir_p(File.dirname(file))
191
- File.open(file, 'wb') { |f| a.save(f) }
192
- files << file
193
- else
194
- @warnings << "Attachment #{a.properties[:display_name]} cannot be extracted"
195
- next
196
- end
197
- i += 1
90
+ return {
91
+ mime_type: attachment.mime_type,
92
+ base64: Base64.encode64(attachment.data).gsub(/[\r\n]/, '')
93
+ }
198
94
  end
95
+ nil
96
+ end
199
97
 
200
- if root_msg
201
- p = Pathname(File.dirname(files.first))
202
- files[1..].each do |f|
203
- (headers[:attachments] ||= []) << Pathname.new(f).relative_path_from(p).to_s
204
- end
98
+ def get_file_attachments(attachments, _used_files)
99
+ attachments.select do |attachment|
100
+ !attachment.content_id && !attachment.embedded_message? && attachment.filename
205
101
  end
102
+ end
206
103
 
207
- {
208
- command: { status: 0 },
209
- files:,
210
- headers:,
211
- warnings: @warnings
212
- }
213
- rescue Exception => e
214
- raise unless root_msg
215
-
216
- msg.close
217
- {
218
- command: { status: -1 },
219
- files: [],
220
- headers: {},
221
- errors: [
222
- {
223
- error: e.message,
224
- error_class: e.class.name,
225
- error_trace: e.backtrace
226
- }
227
- ],
228
- warnings: @warnings
229
- }
104
+ def get_mail_attachments(attachments)
105
+ attachments.select(&:embedded_message?)
230
106
  end
231
107
 
232
- protected
108
+ def get_attachment_info(attachment)
109
+ if attachment.embedded_message?
233
110
 
234
- def eml_to_html; end
111
+ {
112
+ embedded_msg: attachment.data,
113
+ filename: attachment.message&.subject || 'email'
114
+ }
235
115
 
236
- private
116
+ elsif attachment.filename
237
117
 
238
- def find_hdr(list, key)
239
- keys = list.keys
240
- if (k = keys.find { |x| x.to_s =~ /^#{key}$/i })
241
- v = list[k]
242
- v = v.first if v.is_a? Array
243
- v = Rfc2047.decode(v).strip if v.is_a? String
244
- return v
118
+ {
119
+ data: attachment.data,
120
+ filename: attachment.filename
121
+ }
122
+
123
+ else
124
+ {
125
+ filename: attachment.mime_type || 'unknown'
126
+ }
245
127
  end
246
- nil
247
128
  end
248
129
 
249
- def hdr_html(key, value)
250
- return format(HEADER_FIELD_TEMPLATE, key, CGI.escapeHTML(value)) if key.is_a?(String) && value.is_a?(String) && !value.empty?
130
+ private
251
131
 
252
- ''
253
- end
132
+ def find_hdr(msg, key)
133
+ value = if key.casecmp('From').zero?
134
+ msg.sender
135
+ else
136
+ msg.send(key.downcase.to_sym)
137
+ end
254
138
 
255
- def get_attachment_data(attachments, cid)
256
- attachments.each do |attachment|
257
- next unless attachment.properties.attach_content_id == cid
139
+ if value.is_a?(Array)
140
+ value.compact.empty? ? nil : value.compact.map(&:to_s).join(', ')
141
+
142
+ elsif value.is_a?(Time)
143
+ value.localtime
144
+
145
+ else
146
+ value.to_s
258
147
 
259
- attachment.data.rewind
260
- return {
261
- mime_type: attachment.properties.attach_mime_tag,
262
- base64: Base64.encode64(attachment.data.read).gsub(/[\r\n]/, '')
263
- }
264
148
  end
265
- nil
266
149
  end
267
150
  end
268
151
  end
269
152
  end
270
153
  end
154
+ # rubocop:enable Style/Documentation, Metrics/*
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Libis
4
4
  module Format
5
- VERSION = '1.3.7.2'
5
+ VERSION = '1.3.8.1'
6
6
  end
7
7
  end
data/libis-format.gemspec CHANGED
@@ -16,7 +16,7 @@ Gem::Specification.new do |spec|
16
16
  spec.homepage = ''
17
17
  spec.license = 'MIT'
18
18
 
19
- spec.platform = Gem::Platform::JAVA if defined?(RUBY_ENGINE) && RUBY_ENGINE == 'jruby'
19
+ # spec.platform = Gem::Platform::JAVA if defined?(RUBY_ENGINE) && RUBY_ENGINE == 'jruby'
20
20
  spec.required_ruby_version = '>= 3.2'
21
21
 
22
22
  spec.files = `git ls-files -z`.split("\x0").select do |f|
@@ -27,14 +27,18 @@ Gem::Specification.new do |spec|
27
27
 
28
28
  spec.add_runtime_dependency 'chromaprint', '~> 0.0.2'
29
29
  spec.add_runtime_dependency 'deep_dive', '~> 0.3'
30
- spec.add_runtime_dependency 'libis-mapi', '~> 0.3'
30
+ # spec.add_runtime_dependency 'libis-mapi', '~> 0.3'
31
31
  spec.add_runtime_dependency 'libis-tools', '~> 1.1'
32
+ spec.add_runtime_dependency 'mail', '~> 2.9'
32
33
  spec.add_runtime_dependency 'mini_magick', '~> 5.0.1'
34
+ spec.add_runtime_dependency 'msg_extractor', '~> 0.1'
33
35
  spec.add_runtime_dependency 'naturally', '~> 2.2'
34
36
  spec.add_runtime_dependency 'new_rfc_2047', '~> 1.0'
35
37
  spec.add_runtime_dependency 'os', '~> 1.1'
36
38
  spec.add_runtime_dependency 'pdfinfo', '~> 1.4'
37
39
  spec.add_runtime_dependency 'pdfkit', '~> 0.8'
40
+ # spec.add_runtime_dependency 'ruby-msg', '~> 1.5.3.1'
41
+ spec.add_runtime_dependency 'word_wrap', '~> 1.0'
38
42
 
39
43
  spec.add_development_dependency 'awesome_print'
40
44
  spec.add_development_dependency 'equivalent-xml'
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: libis-format
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.7.2
4
+ version: 1.3.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kris Dekeyser
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2025-06-18 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: chromaprint
@@ -39,33 +38,33 @@ dependencies:
39
38
  - !ruby/object:Gem::Version
40
39
  version: '0.3'
41
40
  - !ruby/object:Gem::Dependency
42
- name: libis-mapi
41
+ name: libis-tools
43
42
  requirement: !ruby/object:Gem::Requirement
44
43
  requirements:
45
44
  - - "~>"
46
45
  - !ruby/object:Gem::Version
47
- version: '0.3'
46
+ version: '1.1'
48
47
  type: :runtime
49
48
  prerelease: false
50
49
  version_requirements: !ruby/object:Gem::Requirement
51
50
  requirements:
52
51
  - - "~>"
53
52
  - !ruby/object:Gem::Version
54
- version: '0.3'
53
+ version: '1.1'
55
54
  - !ruby/object:Gem::Dependency
56
- name: libis-tools
55
+ name: mail
57
56
  requirement: !ruby/object:Gem::Requirement
58
57
  requirements:
59
58
  - - "~>"
60
59
  - !ruby/object:Gem::Version
61
- version: '1.1'
60
+ version: '2.9'
62
61
  type: :runtime
63
62
  prerelease: false
64
63
  version_requirements: !ruby/object:Gem::Requirement
65
64
  requirements:
66
65
  - - "~>"
67
66
  - !ruby/object:Gem::Version
68
- version: '1.1'
67
+ version: '2.9'
69
68
  - !ruby/object:Gem::Dependency
70
69
  name: mini_magick
71
70
  requirement: !ruby/object:Gem::Requirement
@@ -80,6 +79,20 @@ dependencies:
80
79
  - - "~>"
81
80
  - !ruby/object:Gem::Version
82
81
  version: 5.0.1
82
+ - !ruby/object:Gem::Dependency
83
+ name: msg_extractor
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '0.1'
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '0.1'
83
96
  - !ruby/object:Gem::Dependency
84
97
  name: naturally
85
98
  requirement: !ruby/object:Gem::Requirement
@@ -150,6 +163,20 @@ dependencies:
150
163
  - - "~>"
151
164
  - !ruby/object:Gem::Version
152
165
  version: '0.8'
166
+ - !ruby/object:Gem::Dependency
167
+ name: word_wrap
168
+ requirement: !ruby/object:Gem::Requirement
169
+ requirements:
170
+ - - "~>"
171
+ - !ruby/object:Gem::Version
172
+ version: '1.0'
173
+ type: :runtime
174
+ prerelease: false
175
+ version_requirements: !ruby/object:Gem::Requirement
176
+ requirements:
177
+ - - "~>"
178
+ - !ruby/object:Gem::Version
179
+ version: '1.0'
153
180
  - !ruby/object:Gem::Dependency
154
181
  name: awesome_print
155
182
  requirement: !ruby/object:Gem::Requirement
@@ -273,12 +300,14 @@ files:
273
300
  - lib/libis/format/identifier.rb
274
301
  - lib/libis/format/tool.rb
275
302
  - lib/libis/format/tool/droid.rb
303
+ - lib/libis/format/tool/eml_to_pdf.rb
276
304
  - lib/libis/format/tool/extension_identification.rb
277
305
  - lib/libis/format/tool/ff_mpeg.rb
278
306
  - lib/libis/format/tool/fido.rb
279
307
  - lib/libis/format/tool/file_tool.rb
280
308
  - lib/libis/format/tool/fop_pdf.rb
281
309
  - lib/libis/format/tool/identification_tool.rb
310
+ - lib/libis/format/tool/mail_to_pdf.rb
282
311
  - lib/libis/format/tool/msg_to_pdf.rb
283
312
  - lib/libis/format/tool/office_to_pdf.rb
284
313
  - lib/libis/format/tool/pdf_merge.rb
@@ -352,7 +381,6 @@ homepage: ''
352
381
  licenses:
353
382
  - MIT
354
383
  metadata: {}
355
- post_install_message:
356
384
  rdoc_options: []
357
385
  require_paths:
358
386
  - lib
@@ -367,8 +395,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
367
395
  - !ruby/object:Gem::Version
368
396
  version: '0'
369
397
  requirements: []
370
- rubygems_version: 3.4.19
371
- signing_key:
398
+ rubygems_version: 4.0.8
372
399
  specification_version: 4
373
400
  summary: LIBIS File format format services.
374
401
  test_files: []