postpeek 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +43 -0
- data/Rakefile +12 -0
- data/lib/postpeek/configuration.rb +67 -0
- data/lib/postpeek/decoders/base.rb +21 -0
- data/lib/postpeek/decoders/base64.rb +21 -0
- data/lib/postpeek/decoders/errors.rb +28 -0
- data/lib/postpeek/decoders/quoted_printable.rb +26 -0
- data/lib/postpeek/decoders/registry.rb +71 -0
- data/lib/postpeek/decoders/seven_bit.rb +17 -0
- data/lib/postpeek/delivery_method.rb +113 -0
- data/lib/postpeek/hooks/lifecycle.rb +17 -0
- data/lib/postpeek/id_generator.rb +11 -0
- data/lib/postpeek/message.rb +48 -0
- data/lib/postpeek/metadata/action_mailer_context.rb +41 -0
- data/lib/postpeek/metadata/builder.rb +66 -0
- data/lib/postpeek/metadata/extractor.rb +104 -0
- data/lib/postpeek/metadata/interceptor.rb +12 -0
- data/lib/postpeek/railtie.rb +13 -0
- data/lib/postpeek/storage/base.rb +39 -0
- data/lib/postpeek/storage/file_system.rb +104 -0
- data/lib/postpeek/version.rb +5 -0
- data/lib/postpeek.rb +44 -0
- data/mise.toml +2 -0
- data/sig/postpeek.rbs +4 -0
- metadata +105 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 22be47bd1a344e954412e45df776751e211891d001d4c136743d3b83f46fb767
|
|
4
|
+
data.tar.gz: 5eb48d5c14bd41fbebcdf835d3c5471604ec5b74e04c2842656a809580105f24
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: b039b3e4204cef36f83e7d91128f40ad3e5eb78b27a0f0fb4fa751ec6ed18dd7d6ce06731bb70360e4c9234a14c0ab79110e8295134eefdb4d7f4dd0a5b1a997
|
|
7
|
+
data.tar.gz: ed09f1c6ce00ea05bf7b94e9489796f1f6e17e13c91fbbdd7b253bd64f586653564356c1c62c04c09038630893c491d6febf4d0c928c7e6e5d482c1d33eaf8da
|
data/CHANGELOG.md
ADDED
data/CODE_OF_CONDUCT.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Code of Conduct
|
|
2
|
+
|
|
3
|
+
"postpeek" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
|
|
4
|
+
|
|
5
|
+
* Participants will be tolerant of opposing views.
|
|
6
|
+
* Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
|
|
7
|
+
* When interpreting the words and actions of others, participants should always assume good intentions.
|
|
8
|
+
* Behaviour which can be reasonably considered harassment will not be tolerated.
|
|
9
|
+
|
|
10
|
+
If you have any concerns about behaviour within this project, please contact us at ["mmarusyk1@gmail.com"](mailto:"mmarusyk1@gmail.com").
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mykhailo Marusyk
|
|
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.
|
data/README.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Postpeek
|
|
2
|
+
|
|
3
|
+
TODO: Delete this and the text below, and describe your gem
|
|
4
|
+
|
|
5
|
+
Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/postpeek`. To experiment with that code, run `bin/console` for an interactive prompt.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
|
|
10
|
+
|
|
11
|
+
Install the gem and add to the application's Gemfile by executing:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
If bundler is not being used to manage dependencies, install the gem by executing:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
TODO: Write usage instructions here
|
|
26
|
+
|
|
27
|
+
## Development
|
|
28
|
+
|
|
29
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
30
|
+
|
|
31
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
|
32
|
+
|
|
33
|
+
## Contributing
|
|
34
|
+
|
|
35
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/postpeek. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/[USERNAME]/postpeek/blob/master/CODE_OF_CONDUCT.md).
|
|
36
|
+
|
|
37
|
+
## License
|
|
38
|
+
|
|
39
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
40
|
+
|
|
41
|
+
## Code of Conduct
|
|
42
|
+
|
|
43
|
+
Everyone interacting in the Postpeek project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/postpeek/blob/master/CODE_OF_CONDUCT.md).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module Postpeek
|
|
6
|
+
class Configuration
|
|
7
|
+
DEFAULTS = {
|
|
8
|
+
storage_path: nil,
|
|
9
|
+
storage_backend: :file_system,
|
|
10
|
+
unknown_encoding: :raise,
|
|
11
|
+
max_emails: 500,
|
|
12
|
+
auto_prune: true,
|
|
13
|
+
open_in_browser: false,
|
|
14
|
+
after_save: [],
|
|
15
|
+
metadata_builders: []
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
VALID_UNKNOWN_ENCODING_VALUES = %i[raise warn skip].freeze
|
|
19
|
+
|
|
20
|
+
attr_accessor :storage_path, :storage_backend, :unknown_encoding,
|
|
21
|
+
:max_emails, :auto_prune, :open_in_browser,
|
|
22
|
+
:after_save, :metadata_builders
|
|
23
|
+
|
|
24
|
+
def initialize
|
|
25
|
+
DEFAULTS.each { |key, value| public_send(:"#{key}=", deep_dup(value)) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def resolved_storage_path
|
|
29
|
+
path = storage_path ||
|
|
30
|
+
(defined?(Rails) && Rails.respond_to?(:root) && Rails.root ? Rails.root.join("tmp", "postpeek") : nil) ||
|
|
31
|
+
Pathname.new(Dir.pwd).join("tmp", "postpeek")
|
|
32
|
+
|
|
33
|
+
Pathname.new(path)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def after_save_hook(callable = nil, &block)
|
|
37
|
+
after_save << (callable || block)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def metadata_builder(callable = nil, &block)
|
|
41
|
+
metadata_builders << (callable || block)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def validate!
|
|
45
|
+
unless VALID_UNKNOWN_ENCODING_VALUES.include?(unknown_encoding)
|
|
46
|
+
raise ArgumentError,
|
|
47
|
+
"unknown_encoding must be one of #{VALID_UNKNOWN_ENCODING_VALUES.inspect}, " \
|
|
48
|
+
"got #{unknown_encoding.inspect}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
if !max_emails.nil? && (!max_emails.is_a?(Integer) || max_emails <= 0)
|
|
52
|
+
raise ArgumentError, "max_emails must be a positive integer or nil, got #{max_emails.inspect}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
self
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def deep_dup(value)
|
|
61
|
+
case value
|
|
62
|
+
when Array then value.dup
|
|
63
|
+
else value
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Postpeek
|
|
4
|
+
module Decoders
|
|
5
|
+
class Base
|
|
6
|
+
# Returns the list of encoding strings this decoder handles.
|
|
7
|
+
# @return [Array<String>]
|
|
8
|
+
def self.handles
|
|
9
|
+
raise NotImplementedError, "#{self}.handles must be implemented by subclass"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Decodes raw encoded content.
|
|
13
|
+
# @param content [String] encoded content
|
|
14
|
+
# @return [String] decoded content encoded as UTF-8
|
|
15
|
+
# @raise [DecodeError] if decoding fails
|
|
16
|
+
def decode(content)
|
|
17
|
+
raise NotImplementedError, "#{self.class}#decode must be implemented by subclass"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
|
|
5
|
+
module Postpeek
|
|
6
|
+
module Decoders
|
|
7
|
+
class Base64 < Base
|
|
8
|
+
def self.handles
|
|
9
|
+
["base64"].freeze
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def decode(content)
|
|
13
|
+
decoded = ::Base64.decode64(content)
|
|
14
|
+
|
|
15
|
+
decoded.encode("UTF-8", invalid: :replace, undef: :replace, replace: "")
|
|
16
|
+
rescue ArgumentError => e
|
|
17
|
+
raise DecodeError.new("base64", e)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Postpeek
|
|
4
|
+
module Decoders
|
|
5
|
+
class UnknownEncodingError < Postpeek::Error
|
|
6
|
+
attr_reader :encoding
|
|
7
|
+
|
|
8
|
+
def initialize(encoding)
|
|
9
|
+
@encoding = encoding
|
|
10
|
+
|
|
11
|
+
super("No decoder registered for encoding: #{encoding.inspect}")
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
class DecodeError < Postpeek::Error
|
|
16
|
+
attr_reader :encoding, :original_error
|
|
17
|
+
|
|
18
|
+
def initialize(encoding, original_error = nil)
|
|
19
|
+
@encoding = encoding
|
|
20
|
+
@original_error = original_error
|
|
21
|
+
message = "Failed to decode content with encoding #{encoding.inspect}"
|
|
22
|
+
message += ": #{original_error.message}" if original_error
|
|
23
|
+
|
|
24
|
+
super(message)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Postpeek
|
|
4
|
+
module Decoders
|
|
5
|
+
class QuotedPrintable < Base
|
|
6
|
+
def self.handles
|
|
7
|
+
["quoted-printable"].freeze
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def decode(content)
|
|
11
|
+
# unpack1("M") returns an ASCII-8BIT string; force UTF-8 interpretation
|
|
12
|
+
# before transcoding so multi-byte sequences are preserved.
|
|
13
|
+
decoded = content.unpack1("M").force_encoding("UTF-8")
|
|
14
|
+
|
|
15
|
+
if decoded.valid_encoding?
|
|
16
|
+
decoded
|
|
17
|
+
else
|
|
18
|
+
decoded.encode("UTF-8", "binary", invalid: :replace, undef: :replace,
|
|
19
|
+
replace: "")
|
|
20
|
+
end
|
|
21
|
+
rescue ArgumentError => e
|
|
22
|
+
raise DecodeError.new("quoted-printable", e)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Postpeek
|
|
4
|
+
module Decoders
|
|
5
|
+
class Registry
|
|
6
|
+
class << self
|
|
7
|
+
# Registers a decoder class for all encodings it handles.
|
|
8
|
+
# @param decoder_class [Class<Base>]
|
|
9
|
+
def register(decoder_class)
|
|
10
|
+
decoder_class.handles.each do |encoding|
|
|
11
|
+
registry[encoding.downcase] = decoder_class
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Returns an instantiated decoder for the given encoding.
|
|
16
|
+
# Behaviour on unknown encoding is controlled by config.unknown_encoding:
|
|
17
|
+
# :raise — raises UnknownEncodingError (default)
|
|
18
|
+
# :warn — logs a warning and returns nil
|
|
19
|
+
# :skip — silently returns nil
|
|
20
|
+
# @param encoding [String]
|
|
21
|
+
# @param config [Postpeek::Configuration]
|
|
22
|
+
# @return [Base, nil]
|
|
23
|
+
def find(encoding, config: Postpeek.configuration)
|
|
24
|
+
decoder_class = registry[encoding.to_s.downcase]
|
|
25
|
+
|
|
26
|
+
return decoder_class.new if decoder_class
|
|
27
|
+
|
|
28
|
+
handle_unknown_encoding(encoding, config)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Returns a frozen copy of the current registry map.
|
|
32
|
+
# @return [Hash{String => Class}]
|
|
33
|
+
def registered
|
|
34
|
+
registry.dup.freeze
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Resets the registry to the built-in defaults. Intended for use in tests only.
|
|
38
|
+
def reset!
|
|
39
|
+
@registry = nil
|
|
40
|
+
|
|
41
|
+
seed_built_ins
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def handle_unknown_encoding(encoding, config)
|
|
47
|
+
case config.unknown_encoding
|
|
48
|
+
when :raise then raise UnknownEncodingError, encoding
|
|
49
|
+
when :warn
|
|
50
|
+
warn "Postpeek: no decoder registered for encoding #{encoding.inspect}, skipping"
|
|
51
|
+
end
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def registry
|
|
56
|
+
@registry ||= {}.tap { seed_built_ins_into(_1) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def seed_built_ins
|
|
60
|
+
seed_built_ins_into(registry)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def seed_built_ins_into(hash)
|
|
64
|
+
[QuotedPrintable, Base64, SevenBit].each do |klass|
|
|
65
|
+
klass.handles.each { |enc| hash[enc.downcase] = klass }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Postpeek
|
|
4
|
+
module Decoders
|
|
5
|
+
class SevenBit < Base
|
|
6
|
+
def self.handles
|
|
7
|
+
%w[7bit 8bit binary].freeze
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def decode(content)
|
|
11
|
+
content.encode("UTF-8", invalid: :replace, undef: :replace, replace: "")
|
|
12
|
+
rescue ArgumentError => e
|
|
13
|
+
raise DecodeError.new("7bit", e)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Postpeek
|
|
4
|
+
class DeliveryMethod
|
|
5
|
+
# @param settings [Hash] from ActionMailer::Base.postpeek_settings (unused in v0.1)
|
|
6
|
+
def initialize(settings = {})
|
|
7
|
+
@settings = settings
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Full pipeline:
|
|
11
|
+
# 1. Record start time
|
|
12
|
+
# 2. Decode parts (html_body, text_body, attachments)
|
|
13
|
+
# 3. Compute delivery_duration_ms
|
|
14
|
+
# 4. Build metadata via Metadata::Builder
|
|
15
|
+
# 5. Construct Message value object
|
|
16
|
+
# 6. Persist via storage backend
|
|
17
|
+
# 7. Run after_save hooks
|
|
18
|
+
def deliver!(mail)
|
|
19
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
20
|
+
html_body, text_body, attachments = decode_parts(mail)
|
|
21
|
+
duration_ms = elapsed_ms(start_time)
|
|
22
|
+
|
|
23
|
+
metadata = Metadata::Builder.new(mail, config: config, delivery_duration_ms: duration_ms).build
|
|
24
|
+
message = Message.new(mail: mail, metadata: metadata,
|
|
25
|
+
html_body: html_body, text_body: text_body, attachments: attachments)
|
|
26
|
+
|
|
27
|
+
storage.save(message)
|
|
28
|
+
Hooks::Lifecycle.run_after_save(message, config: config)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def config
|
|
34
|
+
Postpeek.configuration
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def elapsed_ms(start_time)
|
|
38
|
+
((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Walks mail parts and decodes each using Decoders::Registry.
|
|
42
|
+
# @return [Array(String|nil, String|nil, Array<Hash>)]
|
|
43
|
+
# [html_body, text_body, [{filename:, content_type:, data:}, ...]]
|
|
44
|
+
def decode_parts(mail)
|
|
45
|
+
parts = mail.multipart? ? mail.parts : [mail]
|
|
46
|
+
results = parts.map { |p| decode_one_part(p) }
|
|
47
|
+
html_body = results.filter_map { |h, _, _| h }.first
|
|
48
|
+
text_body = results.filter_map { |_, t, _| t }.first
|
|
49
|
+
attachments = results.flat_map { |_, _, a| a }
|
|
50
|
+
[html_body, text_body, attachments]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Recursively decodes one part.
|
|
54
|
+
# @return [Array(String|nil, String|nil, Array<Hash>)]
|
|
55
|
+
def decode_one_part(part)
|
|
56
|
+
return recurse_multipart(part) if part.multipart?
|
|
57
|
+
return [nil, nil, [decode_attachment(part)]] if part.attachment?
|
|
58
|
+
|
|
59
|
+
decode_text_part(part)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def recurse_multipart(part)
|
|
63
|
+
results = part.parts.map { |sub| decode_one_part(sub) }
|
|
64
|
+
[
|
|
65
|
+
results.filter_map { |h, _, _| h }.first,
|
|
66
|
+
results.filter_map { |_, t, _| t }.first,
|
|
67
|
+
results.flat_map { |_, _, a| a }
|
|
68
|
+
]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def decode_text_part(part)
|
|
72
|
+
if part.mime_type&.include?("text/html")
|
|
73
|
+
[decode_part(part), nil, []]
|
|
74
|
+
elsif part.mime_type&.include?("text/plain") || part.mime_type.nil?
|
|
75
|
+
[nil, decode_part(part), []]
|
|
76
|
+
else
|
|
77
|
+
[nil, nil, []]
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Decodes a single mail part. Returns nil if decoder returns nil (skip mode).
|
|
82
|
+
# Defaults to "7bit" when no Content-Transfer-Encoding header is present (RFC 2822).
|
|
83
|
+
def decode_part(part)
|
|
84
|
+
encoding = part.content_transfer_encoding.to_s
|
|
85
|
+
encoding = "7bit" if encoding.empty?
|
|
86
|
+
decoder = Decoders::Registry.find(encoding, config: config)
|
|
87
|
+
return nil if decoder.nil?
|
|
88
|
+
|
|
89
|
+
decoder.decode(part.body.raw_source)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def decode_attachment(part)
|
|
93
|
+
{
|
|
94
|
+
filename: part.filename || "attachment",
|
|
95
|
+
content_type: part.mime_type,
|
|
96
|
+
data: part.body.decoded
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Returns the storage backend instance (memoized).
|
|
101
|
+
def storage
|
|
102
|
+
@storage ||= resolve_storage_backend.new(config)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def resolve_storage_backend
|
|
106
|
+
case config.storage_backend
|
|
107
|
+
when :file_system then Storage::FileSystem
|
|
108
|
+
when Class then config.storage_backend
|
|
109
|
+
else raise ArgumentError, "Unknown storage backend: #{config.storage_backend.inspect}"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Postpeek
|
|
4
|
+
module Hooks
|
|
5
|
+
module Lifecycle
|
|
6
|
+
# @param message [Postpeek::Message]
|
|
7
|
+
# @param config [Postpeek::Configuration]
|
|
8
|
+
def self.run_after_save(message, config:)
|
|
9
|
+
config.after_save.each do |hook|
|
|
10
|
+
hook.call(message)
|
|
11
|
+
rescue StandardError => e
|
|
12
|
+
warn "Postpeek after_save hook raised: #{e.class}: #{e.message}"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Postpeek
|
|
4
|
+
class Message
|
|
5
|
+
# @param mail [Mail::Message]
|
|
6
|
+
# @param metadata [Hash] from Builder#build
|
|
7
|
+
# @param html_body [String, nil] decoded HTML part
|
|
8
|
+
# @param text_body [String, nil] decoded plain-text part
|
|
9
|
+
# @param attachments [Array<Hash>] [{filename:, content_type:, data: <binary>}, ...]
|
|
10
|
+
def initialize(mail:, metadata:, html_body: nil, text_body: nil, attachments: [])
|
|
11
|
+
@mail = mail
|
|
12
|
+
@metadata = metadata
|
|
13
|
+
@html_body = html_body
|
|
14
|
+
@text_body = text_body
|
|
15
|
+
@attachments = attachments
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
attr_reader :mail, :metadata, :html_body, :text_body, :attachments
|
|
19
|
+
|
|
20
|
+
def id
|
|
21
|
+
metadata[:id]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def subject
|
|
25
|
+
metadata[:subject]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def from
|
|
29
|
+
metadata[:from]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def to
|
|
33
|
+
metadata[:to]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def mailer_class
|
|
37
|
+
metadata[:mailer_class]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def mailer_action
|
|
41
|
+
metadata[:mailer_action]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def raw_source
|
|
45
|
+
mail.to_s
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Postpeek
|
|
4
|
+
module Metadata
|
|
5
|
+
module ActionMailerContext
|
|
6
|
+
THREAD_KEY_CLASS = :postpeek_mailer_class
|
|
7
|
+
THREAD_KEY_ACTION = :postpeek_mailer_action
|
|
8
|
+
|
|
9
|
+
# Stores mailer context in Thread.current, yields, clears in ensure.
|
|
10
|
+
# @param mailer_class [String]
|
|
11
|
+
# @param action_name [String]
|
|
12
|
+
def self.with(mailer_class, action_name)
|
|
13
|
+
Thread.current[THREAD_KEY_CLASS] = mailer_class
|
|
14
|
+
Thread.current[THREAD_KEY_ACTION] = action_name
|
|
15
|
+
yield
|
|
16
|
+
ensure
|
|
17
|
+
Thread.current[THREAD_KEY_CLASS] = nil
|
|
18
|
+
Thread.current[THREAD_KEY_ACTION] = nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.current_mailer_class
|
|
22
|
+
Thread.current[THREAD_KEY_CLASS]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.current_action
|
|
26
|
+
Thread.current[THREAD_KEY_ACTION]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Installs an around_action on ActionMailer::Base.
|
|
30
|
+
# Idempotent: guarded by @installed class-level flag.
|
|
31
|
+
def self.install!
|
|
32
|
+
return if @installed
|
|
33
|
+
|
|
34
|
+
@installed = true
|
|
35
|
+
ActionMailer::Base.around_action do |mailer, action|
|
|
36
|
+
ActionMailerContext.with(mailer.class.name, mailer.action_name) { action.call }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Postpeek
|
|
4
|
+
module Metadata
|
|
5
|
+
class Builder
|
|
6
|
+
# @param mail [Mail::Message]
|
|
7
|
+
# @param config [Postpeek::Configuration]
|
|
8
|
+
# @param delivery_duration_ms [Integer, nil]
|
|
9
|
+
def initialize(mail, config:, delivery_duration_ms: nil)
|
|
10
|
+
@mail = mail
|
|
11
|
+
@config = config
|
|
12
|
+
@delivery_duration_ms = delivery_duration_ms
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Pipeline:
|
|
16
|
+
# 1. Extractor#to_h — base fields
|
|
17
|
+
# 2. mailer_class + mailer_action from ActionMailerContext
|
|
18
|
+
# (fallback: X-Postpeek-* headers when thread context is nil)
|
|
19
|
+
# 3. id = IdGenerator.generate
|
|
20
|
+
# 4. delivery_duration_ms (from arg)
|
|
21
|
+
# 5. tags: []
|
|
22
|
+
# 6. Call each config.metadata_builders callable(hash); merge result
|
|
23
|
+
# @return [Hash]
|
|
24
|
+
def build
|
|
25
|
+
hash = Extractor.new(mail).to_h
|
|
26
|
+
hash[:mailer_class] = mailer_class_value
|
|
27
|
+
hash[:mailer_action] = mailer_action_value
|
|
28
|
+
hash[:id] = IdGenerator.generate
|
|
29
|
+
hash[:delivery_duration_ms] = delivery_duration_ms
|
|
30
|
+
hash[:tags] = []
|
|
31
|
+
apply_metadata_builders(hash)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
attr_reader :mail, :config, :delivery_duration_ms
|
|
37
|
+
|
|
38
|
+
def apply_metadata_builders(hash)
|
|
39
|
+
config.metadata_builders.each do |builder|
|
|
40
|
+
result = builder.call(hash)
|
|
41
|
+
|
|
42
|
+
raise ArgumentError, "metadata_builder must return a Hash, got #{result.class}" unless result.is_a?(Hash)
|
|
43
|
+
|
|
44
|
+
hash.merge!(result)
|
|
45
|
+
end
|
|
46
|
+
hash
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def mailer_class_value
|
|
50
|
+
ActionMailerContext.current_mailer_class ||
|
|
51
|
+
header_string("X-Postpeek-Mailer-Class")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def mailer_action_value
|
|
55
|
+
ActionMailerContext.current_action ||
|
|
56
|
+
header_string("X-Postpeek-Mailer-Action")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def header_string(name)
|
|
60
|
+
val = mail[name]&.to_s
|
|
61
|
+
|
|
62
|
+
val&.empty? ? nil : val
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Postpeek
|
|
4
|
+
module Metadata
|
|
5
|
+
class Extractor
|
|
6
|
+
def initialize(mail)
|
|
7
|
+
@mail = mail
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def subject
|
|
11
|
+
mail.subject
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def from
|
|
15
|
+
normalise_addresses(mail.from)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to
|
|
19
|
+
normalise_addresses(mail.to)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def cc
|
|
23
|
+
normalise_addresses(mail.cc)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def bcc
|
|
27
|
+
normalise_addresses(mail.bcc)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def reply_to
|
|
31
|
+
normalise_addresses(mail.reply_to).first
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def sent_at
|
|
35
|
+
return nil unless mail.date
|
|
36
|
+
|
|
37
|
+
mail.date.to_time.utc.iso8601
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def content_type
|
|
41
|
+
return nil unless mail.content_type
|
|
42
|
+
|
|
43
|
+
mail.mime_type
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def parts
|
|
47
|
+
return [] unless mail.multipart?
|
|
48
|
+
|
|
49
|
+
mail.parts.map do |part|
|
|
50
|
+
{
|
|
51
|
+
content_type: part.mime_type,
|
|
52
|
+
encoding: part.content_transfer_encoding,
|
|
53
|
+
charset: part.charset
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def encoding
|
|
59
|
+
mail.content_transfer_encoding&.to_s
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def locale
|
|
63
|
+
header_val = mail["X-Mailer-Locale"]&.value
|
|
64
|
+
|
|
65
|
+
return header_val if header_val && !header_val.empty?
|
|
66
|
+
|
|
67
|
+
defined?(I18n) ? I18n.locale&.to_s : nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def attachments
|
|
71
|
+
mail.attachments.map do |att|
|
|
72
|
+
{
|
|
73
|
+
filename: att.filename,
|
|
74
|
+
content_type: att.mime_type,
|
|
75
|
+
size: att.body.decoded.bytesize
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def message_id
|
|
81
|
+
mail.message_id
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def to_h
|
|
85
|
+
{
|
|
86
|
+
subject: subject, from: from, to: to, cc: cc, bcc: bcc,
|
|
87
|
+
reply_to: reply_to, sent_at: sent_at, content_type: content_type,
|
|
88
|
+
parts: parts, encoding: encoding, locale: locale,
|
|
89
|
+
attachments: attachments, message_id: message_id
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
attr_reader :mail
|
|
96
|
+
|
|
97
|
+
def normalise_addresses(field)
|
|
98
|
+
return [] if field.nil?
|
|
99
|
+
|
|
100
|
+
Array(field)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Postpeek
|
|
4
|
+
module Metadata
|
|
5
|
+
module Interceptor
|
|
6
|
+
def self.delivering_email(mail)
|
|
7
|
+
mail["X-Postpeek-Mailer-Class"] = ActionMailerContext.current_mailer_class
|
|
8
|
+
mail["X-Postpeek-Mailer-Action"] = ActionMailerContext.current_action
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Postpeek
|
|
4
|
+
class Railtie < Rails::Railtie
|
|
5
|
+
initializer "postpeek.configure_action_mailer" do
|
|
6
|
+
ActiveSupport.on_load(:action_mailer) do
|
|
7
|
+
ActionMailer::Base.add_delivery_method :postpeek, Postpeek::DeliveryMethod
|
|
8
|
+
Postpeek::Metadata::ActionMailerContext.install!
|
|
9
|
+
ActionMailer::Base.register_interceptor(Postpeek::Metadata::Interceptor)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Postpeek
|
|
4
|
+
module Storage
|
|
5
|
+
class Base
|
|
6
|
+
def initialize(config)
|
|
7
|
+
@config = config
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def save(_message)
|
|
11
|
+
raise NotImplementedError, "#{self.class}#save not implemented"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def list
|
|
15
|
+
raise NotImplementedError, "#{self.class}#list not implemented"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def fetch(_id)
|
|
19
|
+
raise NotImplementedError, "#{self.class}#fetch not implemented"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def delete(_id)
|
|
23
|
+
raise NotImplementedError, "#{self.class}#delete not implemented"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def clear
|
|
27
|
+
raise NotImplementedError, "#{self.class}#clear not implemented"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def count
|
|
31
|
+
raise NotImplementedError, "#{self.class}#count not implemented"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
attr_reader :config
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "pathname"
|
|
6
|
+
|
|
7
|
+
module Postpeek
|
|
8
|
+
module Storage
|
|
9
|
+
class FileSystem < Base
|
|
10
|
+
ID_PATTERN = /\A\d{8}T\d{6}_[0-9a-f]{8}\z/
|
|
11
|
+
|
|
12
|
+
def save(message)
|
|
13
|
+
dir = email_dir(message.id)
|
|
14
|
+
|
|
15
|
+
FileUtils.mkdir_p(dir)
|
|
16
|
+
write_artifacts(dir, message)
|
|
17
|
+
prune_if_needed
|
|
18
|
+
message.id
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def list
|
|
22
|
+
email_dirs.sort_by { |d| -File.mtime(d).to_i }.map { |d| File.basename(d) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def fetch(id)
|
|
26
|
+
path = email_dir(id).join("metadata.json")
|
|
27
|
+
|
|
28
|
+
return nil unless path.exist?
|
|
29
|
+
|
|
30
|
+
JSON.parse(path.read, symbolize_names: true)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def delete(id)
|
|
34
|
+
FileUtils.rm_rf(email_dir(id))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def clear
|
|
38
|
+
email_dirs.each { |d| FileUtils.rm_rf(d) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def count
|
|
42
|
+
email_dirs.length
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def email_dir(id)
|
|
48
|
+
config.resolved_storage_path.join(id)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def email_dirs
|
|
52
|
+
root = config.resolved_storage_path
|
|
53
|
+
return [] unless root.exist?
|
|
54
|
+
|
|
55
|
+
root.children.select { |d| d.directory? && ID_PATTERN.match?(d.basename.to_s) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def prune_if_needed
|
|
59
|
+
return if config.max_emails.nil? || !config.auto_prune
|
|
60
|
+
|
|
61
|
+
while count > config.max_emails
|
|
62
|
+
oldest = email_dirs.min_by { |d| File.mtime(d) }
|
|
63
|
+
FileUtils.rm_rf(oldest)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def write_artifacts(dir, message)
|
|
68
|
+
write_file(dir.join("metadata.json"), JSON.generate(message.metadata))
|
|
69
|
+
write_file(dir.join("message.eml"), message.raw_source)
|
|
70
|
+
write_body_parts(dir, message)
|
|
71
|
+
write_attachments(dir, message.attachments)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def write_body_parts(dir, message)
|
|
75
|
+
write_file(dir.join("rich.html"), message.html_body) if message.html_body
|
|
76
|
+
write_file(dir.join("plain.txt"), message.text_body) if message.text_body
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def write_attachments(dir, attachments)
|
|
80
|
+
return if attachments.empty?
|
|
81
|
+
|
|
82
|
+
att_dir = dir.join("attachments")
|
|
83
|
+
FileUtils.mkdir_p(att_dir)
|
|
84
|
+
attachments.each do |att|
|
|
85
|
+
write_file(att_dir.join(sanitise_filename(att[:filename])), att[:data], binary: true)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def write_file(path, content, binary: false)
|
|
90
|
+
if binary
|
|
91
|
+
File.binwrite(path, content)
|
|
92
|
+
else
|
|
93
|
+
File.write(path, content)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def sanitise_filename(name)
|
|
98
|
+
name.to_s.gsub(%r{[/\\]}, "_").gsub(/[^\w.-]/, "_").then do |safe|
|
|
99
|
+
safe.empty? ? "attachment" : safe
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
data/lib/postpeek.rb
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Postpeek
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
class << self
|
|
7
|
+
# @return [Configuration]
|
|
8
|
+
def configuration
|
|
9
|
+
@configuration ||= Configuration.new
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Yields the configuration object for block-based setup.
|
|
13
|
+
# @yieldparam config [Configuration]
|
|
14
|
+
def configure
|
|
15
|
+
yield configuration
|
|
16
|
+
configuration.validate!
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Resets configuration to defaults. Intended for use in tests only.
|
|
20
|
+
def reset_configuration!
|
|
21
|
+
@configuration = Configuration.new
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
require_relative "postpeek/version"
|
|
27
|
+
require_relative "postpeek/id_generator"
|
|
28
|
+
require_relative "postpeek/configuration"
|
|
29
|
+
require_relative "postpeek/decoders/errors"
|
|
30
|
+
require_relative "postpeek/decoders/base"
|
|
31
|
+
require_relative "postpeek/decoders/quoted_printable"
|
|
32
|
+
require_relative "postpeek/decoders/base64"
|
|
33
|
+
require_relative "postpeek/decoders/seven_bit"
|
|
34
|
+
require_relative "postpeek/decoders/registry"
|
|
35
|
+
require_relative "postpeek/storage/base"
|
|
36
|
+
require_relative "postpeek/storage/file_system"
|
|
37
|
+
require_relative "postpeek/metadata/extractor"
|
|
38
|
+
require_relative "postpeek/metadata/action_mailer_context"
|
|
39
|
+
require_relative "postpeek/metadata/interceptor"
|
|
40
|
+
require_relative "postpeek/metadata/builder"
|
|
41
|
+
require_relative "postpeek/message"
|
|
42
|
+
require_relative "postpeek/hooks/lifecycle"
|
|
43
|
+
require_relative "postpeek/delivery_method"
|
|
44
|
+
require_relative "postpeek/railtie" if defined?(Rails::Railtie)
|
data/mise.toml
ADDED
data/sig/postpeek.rbs
ADDED
metadata
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: postpeek
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Mykhailo Marusyk
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-04-11 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: mail
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '2.8'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '2.8'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: railties
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - ">="
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '6.1'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - ">="
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '6.1'
|
|
41
|
+
description: Postpeek intercepts ActionMailer deliveries in development, decodes content,
|
|
42
|
+
enriches metadata (mailer class, action, duration), and stores each email as a structured
|
|
43
|
+
directory on disk. A companion UI gem (postpeek_ui) reads the stored emails and
|
|
44
|
+
provides a filterable web interface.
|
|
45
|
+
email:
|
|
46
|
+
- mmarusyk1@gmail.com
|
|
47
|
+
executables: []
|
|
48
|
+
extensions: []
|
|
49
|
+
extra_rdoc_files: []
|
|
50
|
+
files:
|
|
51
|
+
- CHANGELOG.md
|
|
52
|
+
- CODE_OF_CONDUCT.md
|
|
53
|
+
- LICENSE.txt
|
|
54
|
+
- README.md
|
|
55
|
+
- Rakefile
|
|
56
|
+
- lib/postpeek.rb
|
|
57
|
+
- lib/postpeek/configuration.rb
|
|
58
|
+
- lib/postpeek/decoders/base.rb
|
|
59
|
+
- lib/postpeek/decoders/base64.rb
|
|
60
|
+
- lib/postpeek/decoders/errors.rb
|
|
61
|
+
- lib/postpeek/decoders/quoted_printable.rb
|
|
62
|
+
- lib/postpeek/decoders/registry.rb
|
|
63
|
+
- lib/postpeek/decoders/seven_bit.rb
|
|
64
|
+
- lib/postpeek/delivery_method.rb
|
|
65
|
+
- lib/postpeek/hooks/lifecycle.rb
|
|
66
|
+
- lib/postpeek/id_generator.rb
|
|
67
|
+
- lib/postpeek/message.rb
|
|
68
|
+
- lib/postpeek/metadata/action_mailer_context.rb
|
|
69
|
+
- lib/postpeek/metadata/builder.rb
|
|
70
|
+
- lib/postpeek/metadata/extractor.rb
|
|
71
|
+
- lib/postpeek/metadata/interceptor.rb
|
|
72
|
+
- lib/postpeek/railtie.rb
|
|
73
|
+
- lib/postpeek/storage/base.rb
|
|
74
|
+
- lib/postpeek/storage/file_system.rb
|
|
75
|
+
- lib/postpeek/version.rb
|
|
76
|
+
- mise.toml
|
|
77
|
+
- sig/postpeek.rbs
|
|
78
|
+
homepage: https://github.com/mmarusyk/postpeek
|
|
79
|
+
licenses:
|
|
80
|
+
- MIT
|
|
81
|
+
metadata:
|
|
82
|
+
homepage_uri: https://github.com/mmarusyk/postpeek
|
|
83
|
+
source_code_uri: https://github.com/mmarusyk/postpeek
|
|
84
|
+
changelog_uri: https://github.com/mmarusyk/postpeek/blob/main/CHANGELOG.md
|
|
85
|
+
rubygems_mfa_required: 'true'
|
|
86
|
+
post_install_message:
|
|
87
|
+
rdoc_options: []
|
|
88
|
+
require_paths:
|
|
89
|
+
- lib
|
|
90
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
91
|
+
requirements:
|
|
92
|
+
- - ">="
|
|
93
|
+
- !ruby/object:Gem::Version
|
|
94
|
+
version: 3.2.0
|
|
95
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
96
|
+
requirements:
|
|
97
|
+
- - ">="
|
|
98
|
+
- !ruby/object:Gem::Version
|
|
99
|
+
version: '0'
|
|
100
|
+
requirements: []
|
|
101
|
+
rubygems_version: 3.4.19
|
|
102
|
+
signing_key:
|
|
103
|
+
specification_version: 4
|
|
104
|
+
summary: Rails mail interceptor that stores emails to disk for development inspection
|
|
105
|
+
test_files: []
|