hanami-mailer 1.3.3 → 3.0.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.
@@ -1,32 +1,37 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- lib = File.expand_path("../lib", __FILE__)
3
+ # This file is synced from hanakai-rb/repo-sync. To update it, edit repo-sync.yml.
4
+
5
+ lib = File.expand_path("lib", __dir__)
4
6
  $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
7
  require "hanami/mailer/version"
6
8
 
7
9
  Gem::Specification.new do |spec|
8
10
  spec.name = "hanami-mailer"
9
- spec.version = Hanami::Mailer::VERSION
10
- spec.authors = ["Luca Guidi"]
11
- spec.email = ["me@lucaguidi.com"]
12
-
13
- spec.summary = "Mail for Ruby applications."
14
- spec.description = "Mail for Ruby applications and Hanami mailers"
15
- spec.homepage = "http://hanamirb.org"
11
+ spec.authors = ["Hanakai team"]
12
+ spec.email = ["info@hanakai.org"]
16
13
  spec.license = "MIT"
14
+ spec.version = Hanami::Mailer::VERSION.dup
17
15
 
18
- spec.files = `git ls-files -- lib/* CHANGELOG.md LICENSE.md README.md hanami-mailer.gemspec`.split($/)
16
+ spec.summary = "Email delivery for Hanami applications and Ruby projects."
17
+ spec.description = spec.summary
18
+ spec.homepage = "https://hanamirb.org"
19
+ spec.files = Dir["CHANGELOG.md", "LICENSE", "README.md", "hanami-mailer.gemspec", "lib/**/*"]
19
20
  spec.bindir = "exe"
20
- spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
21
+ spec.executables = Dir["exe/*"].map { |f| File.basename(f) }
21
22
  spec.require_paths = ["lib"]
22
- spec.required_ruby_version = ">= 2.3.0"
23
23
 
24
- spec.add_dependency "hanami-utils", "~> 1.3"
25
- spec.add_dependency "tilt", "~> 2.0", ">= 2.0.1"
26
- spec.add_dependency "mail", "~> 2.6"
24
+ spec.extra_rdoc_files = ["README.md", "CHANGELOG.md", "LICENSE"]
25
+
26
+ spec.metadata["changelog_uri"] = "https://github.com/hanami/hanami-mailer/blob/main/CHANGELOG.md"
27
+ spec.metadata["source_code_uri"] = "https://github.com/hanami/hanami-mailer"
28
+ spec.metadata["bug_tracker_uri"] = "https://github.com/hanami/hanami-mailer/issues"
29
+ spec.metadata["funding_uri"] = "https://github.com/sponsors/hanami"
27
30
 
28
- spec.add_development_dependency "bundler", ">= 1.6", "< 3"
29
- spec.add_development_dependency "rake", "~> 13"
30
- spec.add_development_dependency "rspec", "~> 3.7"
31
- spec.add_development_dependency "rubocop", "0.81" # rubocop 0.81+ removed support for Ruby 2.3
31
+ spec.required_ruby_version = ">= 3.3"
32
+
33
+ spec.add_runtime_dependency "dry-configurable", "~> 1.0"
34
+ spec.add_runtime_dependency "mail", "~> 2.7"
35
+ spec.add_runtime_dependency "zeitwerk"
32
36
  end
37
+
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hanami
4
+ class Mailer
5
+ # Represents an email attachment
6
+ #
7
+ # @api public
8
+ # @since 3.0.0
9
+ class Attachment
10
+ # Common MIME types for attachments
11
+ #
12
+ # @api private
13
+ MIME_TYPES = {
14
+ ".pdf" => "application/pdf",
15
+ ".zip" => "application/zip",
16
+ ".jpg" => "image/jpeg",
17
+ ".jpeg" => "image/jpeg",
18
+ ".png" => "image/png",
19
+ ".gif" => "image/gif",
20
+ ".txt" => "text/plain",
21
+ ".csv" => "text/csv",
22
+ ".doc" => "application/msword",
23
+ ".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
24
+ ".xls" => "application/vnd.ms-excel",
25
+ ".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
26
+ }.freeze
27
+
28
+ class << self
29
+ # Coerces runtime attachment input into an Attachment
30
+ #
31
+ # @param input [Attachment, Hash] attachment or hash with attachment attributes
32
+ #
33
+ # @return [Attachment]
34
+ #
35
+ # @raise [ArgumentError] if input cannot be coerced
36
+ #
37
+ # @api private
38
+ def from(input)
39
+ case input
40
+ when Attachment
41
+ input
42
+ when Hash
43
+ # Extract keys explicitly rather than splatting so that missing keys arrive as nil,
44
+ # letting the argument checks in #initialize raise clearer errors.
45
+ new(
46
+ filename: input[:filename],
47
+ content: input[:content],
48
+ content_type: input[:content_type],
49
+ inline: input[:inline]
50
+ )
51
+ else
52
+ raise ArgumentError, "Cannot convert #{input.class} to Attachment"
53
+ end
54
+ end
55
+
56
+ # Resolve a static filename from attachment paths and return an Attachment
57
+ #
58
+ # @param filename [String] the filename to resolve
59
+ # @param attachment_paths [Array<String>] paths to search for the file
60
+ # @param inline [Boolean] whether this is an inline attachment
61
+ #
62
+ # @return [Attachment]
63
+ #
64
+ # @raise [MissingAttachmentError] if the file cannot be found
65
+ #
66
+ # @api private
67
+ def from_file(filename, attachment_paths:, inline: false)
68
+ content = read_attachment_file(filename, attachment_paths)
69
+
70
+ # A nested name (e.g. "foo/bar.pdf") is resolved via the search paths, but only its
71
+ # basename should reach the recipient as the attachment's filename and content-id.
72
+ new(filename: File.basename(filename), content:, inline:)
73
+ end
74
+
75
+ private
76
+
77
+ def read_attachment_file(filename, attachment_paths)
78
+ if attachment_paths.any?
79
+ attachment_paths.each do |path|
80
+ full_path = File.join(path, filename)
81
+ return File.read(full_path) if File.exist?(full_path)
82
+ end
83
+ raise MissingAttachmentError.new(filename, attachment_paths)
84
+ elsif File.exist?(filename)
85
+ File.read(filename)
86
+ else
87
+ raise MissingAttachmentError.new(filename, [])
88
+ end
89
+ end
90
+ end
91
+
92
+ # @api private
93
+ attr_reader :filename, :content, :content_type, :content_id
94
+
95
+ # Initialize a new attachment
96
+ #
97
+ # @param filename [String] the filename for the attachment
98
+ # @param content [String, IO] the attachment content
99
+ # @param content_type [String, nil] optional MIME type
100
+ # @param inline [Boolean] whether this is an inline attachment
101
+ #
102
+ # @raise [ArgumentError] if filename or content is missing
103
+ #
104
+ # @api public
105
+ # @since 3.0.0
106
+ def initialize(filename:, content:, content_type: nil, inline: false)
107
+ raise ArgumentError, "filename is required" if filename.nil? || (filename.is_a?(String) && filename.empty?)
108
+ raise ArgumentError, "content is required" if content.nil?
109
+
110
+ @filename = filename
111
+ @content = content
112
+ @content_type = content_type || detect_content_type(filename)
113
+ @inline = inline
114
+ @content_id = inline ? filename : nil
115
+ end
116
+
117
+ # Returns true if this is an inline attachment.
118
+ #
119
+ # @return [Boolean]
120
+ #
121
+ # @api public
122
+ # @since 3.0.0
123
+ def inline? = @inline
124
+
125
+ private
126
+
127
+ def detect_content_type(filename)
128
+ ext = File.extname(filename).downcase
129
+ MIME_TYPES[ext] || "application/octet-stream"
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hanami
4
+ class Mailer
5
+ # A collection of attachments that enforces uniqueness.
6
+ #
7
+ # Aids the preparation of a single email delivery. It is returned from {DSL::Attachments#bind},
8
+ # containing class-level attachment definitions. Runtime attachments can be added via {#concat},
9
+ # and the finalized array is obtained via {#to_a}, which raises if any filenames are duplicated.
10
+ #
11
+ # @api private
12
+ class AttachmentSet
13
+ def initialize(attachments = [])
14
+ @attachments = attachments
15
+ end
16
+
17
+ def concat(runtime_attachments)
18
+ Array(runtime_attachments).each do |attachment|
19
+ @attachments << Attachment.from(attachment)
20
+ end
21
+
22
+ self
23
+ end
24
+
25
+ def to_a
26
+ ensure_unique!
27
+ @attachments.dup
28
+ end
29
+
30
+ private
31
+
32
+ def ensure_unique!
33
+ duplicate = @attachments.map(&:filename).tally.find { |_, count| count > 1 }&.first
34
+ raise DuplicateAttachmentError, duplicate if duplicate
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hanami
4
+ class Mailer
5
+ module Delivery
6
+ # Represents the outcome of a message delivery attempt.
7
+ #
8
+ # This is the base class for delivery results. Delivery methods return an instance of this
9
+ # class (or a subclass) from their #call method. Third-party delivery methods are encouraged
10
+ # to subclass this and add any service-specific attributes they need.
11
+ #
12
+ # @example Checking a result
13
+ # result = mailer.deliver(user: user)
14
+ # if result.success?
15
+ # log.info "Delivered to #{result.message.to.join(', ')}"
16
+ # else
17
+ # log.error "Delivery failed: #{result.error}"
18
+ # end
19
+ #
20
+ # @example A third-party delivery method returning a richer result
21
+ # class Delivery::Postmark::Result < Hanami::Mailer::Delivery::Result
22
+ # attr_reader :message_id, :submitted_at
23
+ #
24
+ # def initialize(message_id:, submitted_at: nil, **)
25
+ # super(**)
26
+ # @message_id = message_id
27
+ # @submitted_at = submitted_at
28
+ # end
29
+ # end
30
+ #
31
+ # @api public
32
+ # @since 3.0.0
33
+ class Result
34
+ # The prepared message that was (or was attempted to be) delivered.
35
+ #
36
+ # @return [Hanami::Mailer::Message]
37
+ #
38
+ # @api public
39
+ # @since 3.0.0
40
+ attr_reader :message
41
+
42
+ # The raw return value from the delivery method, if any.
43
+ #
44
+ # For SMTP delivery this will be the Mail::Message object. For test delivery this will be
45
+ # nil. The exact type is delivery-method-specific; consult the documentation for the
46
+ # delivery method you are using.
47
+ #
48
+ # @return [Object, nil]
49
+ #
50
+ # @api public
51
+ # @since 3.0.0
52
+ attr_reader :response
53
+
54
+ # The error that occurred during delivery, if delivery failed.
55
+ #
56
+ # This is `nil` for a successful delivery. For a failed delivery it is a truthy object
57
+ # describing the error. For {SMTP} deliveries, this will be an exception raised during
58
+ # delivery, but delivery methods are free to represent failures with any object that
59
+ # responds to `#to_s`, allowing for objects that carry richer error details.
60
+ #
61
+ # @return [#to_s, nil] the error if delivery failed, or nil if it succeeded
62
+ #
63
+ # @api public
64
+ # @since 3.0.0
65
+ attr_reader :error
66
+
67
+ # @param message [Hanami::Mailer::Message] the prepared message
68
+ # @param response [Object, nil] the raw response from the delivery method
69
+ # @param error [#to_s, nil] the error, if delivery failed. The result's success status is
70
+ # derived from its absence.
71
+ #
72
+ # @api private
73
+ def initialize(message:, response: nil, error: nil)
74
+ @message = message
75
+ @response = response
76
+ @error = error
77
+ end
78
+
79
+ # Returns true if delivery succeeded.
80
+ #
81
+ # @return [Boolean]
82
+ #
83
+ # @api public
84
+ # @since 3.0.0
85
+ def success?
86
+ error.nil?
87
+ end
88
+
89
+ # Returns true if delivery failed.
90
+ #
91
+ # @return [Boolean]
92
+ #
93
+ # @api public
94
+ # @since 3.0.0
95
+ def failure?
96
+ !success?
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/smtp"
4
+
5
+ module Hanami
6
+ class Mailer
7
+ module Delivery
8
+ # SMTP delivery method
9
+ #
10
+ # @api public
11
+ # @since 3.0.0
12
+ class SMTP
13
+ # Initialize SMTP delivery with configuration
14
+ #
15
+ # @param options [Hash] SMTP configuration options
16
+ #
17
+ # @api private
18
+ def initialize(**options)
19
+ @options = options
20
+ end
21
+
22
+ # Deliver a message via SMTP
23
+ #
24
+ # @param message [Message] the message to deliver
25
+ #
26
+ # @api private
27
+ def call(message)
28
+ mail = to_mail(message)
29
+ mail.delivery_method(:smtp, @options)
30
+
31
+ delivery_exception = nil
32
+ begin
33
+ mail.deliver!
34
+ rescue Net::SMTPError => exception
35
+ delivery_exception = exception
36
+ end
37
+
38
+ Result.new(
39
+ message: message,
40
+ response: mail,
41
+ error: delivery_exception
42
+ )
43
+ end
44
+
45
+ private
46
+
47
+ # Convert a Hanami::Mailer::Message to a Mail::Message
48
+ #
49
+ # @param message [Message] the message to convert
50
+ # @return [Mail::Message]
51
+ def to_mail(message)
52
+ require "mail"
53
+
54
+ mail = build_mail(message)
55
+ assign_body(mail, message)
56
+ add_attachments(mail, message)
57
+
58
+ mail
59
+ end
60
+
61
+ def build_mail(message)
62
+ # Use local variables to avoid shadowing Mail DSL methods
63
+ from_addr = message.from
64
+ to_addr = message.to
65
+ cc_addr = message.cc
66
+ bcc_addr = message.bcc
67
+ reply_to_addr = message.reply_to
68
+ return_path_addr = message.return_path
69
+ subject_text = message.subject
70
+ charset_value = message.charset
71
+
72
+ Mail.new do
73
+ from from_addr
74
+ to to_addr if to_addr
75
+ cc cc_addr if cc_addr
76
+ bcc bcc_addr if bcc_addr
77
+ reply_to reply_to_addr if reply_to_addr
78
+ return_path return_path_addr if return_path_addr
79
+ subject subject_text
80
+ self.charset = charset_value
81
+ message.headers.each { |key, value| self[key] = value }
82
+ end
83
+ end
84
+
85
+ def assign_body(mail, message)
86
+ if message.html_body && message.text_body
87
+ assign_alternative_body(mail, message)
88
+ elsif message.attachments.any?
89
+ assign_single_body_part(mail, message)
90
+ else
91
+ assign_single_body(mail, message)
92
+ end
93
+ end
94
+
95
+ def assign_alternative_body(mail, message)
96
+ # When attachments are present, the bodies must be wrapped in their own
97
+ # multipart/alternative part so Mail produces the correct nested structure:
98
+ # multipart/mixed > [multipart/alternative > [text, html], attachment].
99
+ # Assigning html_part/text_part directly would leave a flat
100
+ # multipart/alternative with the attachments as siblings of the bodies.
101
+ if message.attachments.any?
102
+ mail.add_part(alternative_body_part(message))
103
+ else
104
+ mail.text_part = body_part("text/plain", message.text_body, message.charset)
105
+ mail.html_part = body_part("text/html", message.html_body, message.charset)
106
+ end
107
+ end
108
+
109
+ def assign_single_body_part(mail, message)
110
+ # A single body must be added as a part rather than via #content_type,
111
+ # otherwise Mail pins the message as non-multipart and silently drops
112
+ # the body once the attachment is added.
113
+ mail.add_part(single_body_part(message))
114
+ end
115
+
116
+ def assign_single_body(mail, message)
117
+ if message.html_body
118
+ mail.content_type "text/html; charset=#{message.charset}"
119
+ mail.body = message.html_body
120
+ elsif message.text_body
121
+ mail.content_type "text/plain; charset=#{message.charset}"
122
+ mail.body = message.text_body
123
+ end
124
+ end
125
+
126
+ def alternative_body_part(message)
127
+ Mail::Part.new.tap do |part|
128
+ part.content_type "multipart/alternative"
129
+ part.text_part = body_part("text/plain", message.text_body, message.charset)
130
+ part.html_part = body_part("text/html", message.html_body, message.charset)
131
+ end
132
+ end
133
+
134
+ def single_body_part(message)
135
+ if message.html_body
136
+ body_part("text/html", message.html_body, message.charset)
137
+ else
138
+ body_part("text/plain", message.text_body, message.charset)
139
+ end
140
+ end
141
+
142
+ def body_part(mime_type, content, charset)
143
+ Mail::Part.new do
144
+ content_type "#{mime_type}; charset=#{charset}"
145
+ body content
146
+ end
147
+ end
148
+
149
+ def add_attachments(mail, message)
150
+ message.attachments.each do |attachment|
151
+ if attachment.inline?
152
+ mail.attachments.inline[attachment.filename] = {
153
+ content: attachment.content,
154
+ content_type: attachment.content_type,
155
+ content_id: "<#{attachment.content_id}>"
156
+ }
157
+ else
158
+ mail.attachments[attachment.filename] = {
159
+ content: attachment.content,
160
+ content_type: attachment.content_type
161
+ }
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hanami
4
+ class Mailer
5
+ module Delivery
6
+ # Test delivery method that stores delivery results in memory
7
+ #
8
+ # @api public
9
+ # @since 3.0.0
10
+ class Test
11
+ # Returns all delivery results
12
+ #
13
+ # @return [Array<Delivery::Result>]
14
+ #
15
+ # @api public
16
+ # @since 3.0.0
17
+ def deliveries
18
+ @deliveries ||= []
19
+ end
20
+
21
+ # Clear all delivery results
22
+ #
23
+ # @api public
24
+ # @since 3.0.0
25
+ def clear
26
+ deliveries.clear
27
+ end
28
+
29
+ # Deliver a message by storing a result in memory
30
+ #
31
+ # @param message [Message] the message to deliver
32
+ # @return [Delivery::Result]
33
+ #
34
+ # @api private
35
+ def call(message)
36
+ result = Result.new(message: message)
37
+ deliveries << result
38
+ result
39
+ end
40
+
41
+ # Preview a message without delivering it.
42
+ #
43
+ # Returns the message unchanged. Delivery methods that support service-specific preview
44
+ # logic (e.g. resolving a template from a remote API) can override this method.
45
+ #
46
+ # @param message [Message] the message to preview
47
+ # @return [Message]
48
+ #
49
+ # @api public
50
+ # @since 3.0.0
51
+ def preview(message)
52
+ message
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hanami
4
+ class Mailer
5
+ module DSL
6
+ # Collection of class-level attachment definitions for a mailer.
7
+ #
8
+ # @api private
9
+ class Attachments
10
+ attr_reader :definitions
11
+
12
+ def initialize(definitions = [])
13
+ @definitions = definitions
14
+ end
15
+
16
+ private def initialize_copy(source)
17
+ super
18
+ @definitions = source.definitions.map(&:dup)
19
+ end
20
+
21
+ def add(name_or_filename, proc = nil, **options)
22
+ definitions << Attachment.new(name_or_filename, proc, **options)
23
+ end
24
+
25
+ # Returns a copy with every definition bound to the mailer instance.
26
+ def bind(obj)
27
+ self.class.new(definitions.map { |definition| definition.bind(obj) })
28
+ end
29
+
30
+ # Evaluates the bound definitions, returning an {AttachmentSet} of {Attachment} instances.
31
+ #
32
+ # Each definition's positional parameters resolve against `dependencies` (the mailer's
33
+ # exposure values); its keyword parameters resolve against `input` (the raw `deliver` input).
34
+ def call(input, dependencies: {})
35
+ attachments = definitions.flat_map { |definition| definition.call(input, dependencies) }
36
+
37
+ AttachmentSet.new(attachments)
38
+ end
39
+ end
40
+
41
+ # A class-level attachment definition.
42
+ #
43
+ # @api private
44
+ class Attachment
45
+ attr_reader :name_or_filename, :proc, :options
46
+
47
+ def initialize(name_or_filename, proc = nil, **options)
48
+ @name_or_filename = name_or_filename
49
+ @proc = proc
50
+ @options = options
51
+ end
52
+
53
+ private def initialize_copy(source)
54
+ super
55
+ @options = source.options.dup
56
+ end
57
+
58
+ def bind(obj)
59
+ BoundAttachment.new(name_or_filename, proc, obj, **options)
60
+ end
61
+ end
62
+
63
+ # A bound attachment definition that can be evaluated in the context of a mailer instance.
64
+ #
65
+ # @api private
66
+ class BoundAttachment
67
+ def initialize(name_or_filename, proc, object, **options)
68
+ @name_or_filename = name_or_filename
69
+ @object = object
70
+ @options = options
71
+ @callable = PluckyProc.from_name(proc, name_or_filename, object) || static_callable
72
+ end
73
+
74
+ # Evaluates the attachment definition and returns an array of Attachment objects.
75
+ #
76
+ # Positional parameters resolve against `dependencies` (the mailer's exposure values);
77
+ # keyword parameters resolve against `input`.
78
+ def call(input, dependencies = {})
79
+ Array(@callable.call(input, *dependency_args(dependencies))).each { ensure_attachment(_1) }
80
+ end
81
+
82
+ private
83
+
84
+ def dependency_args(dependencies)
85
+ return [] unless @callable.respond_to?(:dependency_names)
86
+
87
+ @callable.dependency_names.map { |name| dependencies.fetch(name) }
88
+ end
89
+
90
+ def static_callable
91
+ filename = @name_or_filename
92
+ attachment_paths = @object.class.config.attachment_paths
93
+ inline = @options[:inline]
94
+
95
+ ->(*) { Mailer::Attachment.from_file(filename, attachment_paths:, inline:) }
96
+ end
97
+
98
+ def ensure_attachment(value)
99
+ unless value.is_a?(Mailer::Attachment)
100
+ raise ArgumentError, <<~MSG
101
+ Attachment blocks must return Attachment objects. Use the `file` helper method.
102
+ MSG
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end