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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +154 -45
- data/LICENSE +20 -0
- data/README.md +518 -303
- data/hanami-mailer.gemspec +23 -18
- data/lib/hanami/mailer/attachment.rb +133 -0
- data/lib/hanami/mailer/attachment_set.rb +38 -0
- data/lib/hanami/mailer/delivery/result.rb +101 -0
- data/lib/hanami/mailer/delivery/smtp.rb +168 -0
- data/lib/hanami/mailer/delivery/test.rb +57 -0
- data/lib/hanami/mailer/dsl/attachments.rb +108 -0
- data/lib/hanami/mailer/dsl/exposure.rb +69 -0
- data/lib/hanami/mailer/dsl/exposures.rb +111 -0
- data/lib/hanami/mailer/dsl/plucky_proc.rb +135 -0
- data/lib/hanami/mailer/errors.rb +73 -0
- data/lib/hanami/mailer/message.rb +101 -0
- data/lib/hanami/mailer/version.rb +4 -3
- data/lib/hanami/mailer/view_integration.rb +205 -0
- data/lib/hanami/mailer.rb +372 -270
- data/lib/hanami-mailer.rb +1 -1
- metadata +40 -97
- data/LICENSE.md +0 -22
- data/lib/hanami/mailer/configuration.rb +0 -310
- data/lib/hanami/mailer/dsl.rb +0 -628
- data/lib/hanami/mailer/rendering/template_name.rb +0 -55
- data/lib/hanami/mailer/rendering/templates_finder.rb +0 -135
- data/lib/hanami/mailer/template.rb +0 -42
data/hanami-mailer.gemspec
CHANGED
|
@@ -1,32 +1,37 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
10
|
-
spec.
|
|
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.
|
|
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 =
|
|
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.
|
|
25
|
-
|
|
26
|
-
spec.
|
|
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.
|
|
29
|
-
|
|
30
|
-
spec.
|
|
31
|
-
spec.
|
|
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
|