gemstack-mail 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f307a2cbc3114db71b11ebfd91308197c64b1ac2e62ff5de5c228eef8aa55afd
4
+ data.tar.gz: 1154fb801ecab736d9c38f47fc66c3f35038b7b16139c6ae40e2cc603a6e77bb
5
+ SHA512:
6
+ metadata.gz: 377bce3b6a5d4d460e595304fe2d68aba4e4bd847c307fdd7082b7ef91473183baaea175b61185e422851d5f06b1cbb222f546bdcc27fa7a20bedfd30efe0a01
7
+ data.tar.gz: d2bf854eaacf75de80a3b1c32629b9277e33755e6097bac4757712024100748f3f08251162e4fd29db6e1abb1bb058a982c7740afcb4bc6bfd24bf3148ce8cc8
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ First release. See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shoaib Malik
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 all
13
+ 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 THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,28 @@
1
+ # gemstack-mail
2
+
3
+ GemStack mail: mailers, templates, SMTP/log/test delivery, deliver_later via jobs.
4
+
5
+ Part of [GemStack](https://github.com/gemstack-rb/gemstack), a modular Ruby API framework for Next.js
6
+ applications. All GemStack gems are developed together in that repository and released with the same
7
+ version.
8
+
9
+ ## Installation
10
+
11
+ Optional module — `gemstack add auth`, or add it to the Gemfile:
12
+
13
+ ```ruby
14
+ gem "gemstack-mail", "~> 0.1"
15
+ ```
16
+
17
+ ## Documentation
18
+
19
+ - [Guide](https://github.com/gemstack-rb/gemstack/blob/main/docs/mail.md)
20
+ - [All guides](https://github.com/gemstack-rb/gemstack/tree/main/docs) ·
21
+ [Architecture](https://github.com/gemstack-rb/gemstack/blob/main/ARCHITECTURE.md)
22
+
23
+ Source, issues and pull requests: [gemstack-rb/gemstack](https://github.com/gemstack-rb/gemstack)
24
+ (this gem lives in `gems/gemstack-mail`).
25
+
26
+ ## License
27
+
28
+ MIT — see [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "gemstack/jobs"
4
+
5
+ module GemStack
6
+ module Mail
7
+ # Delivers a mailer action in the background (Delivery#deliver_later).
8
+ # Retried like any job; SMTP errors are transient more often than not.
9
+ class DeliveryJob < GemStack::Job
10
+ def self.queue(name = nil) = name ? super : (@queue || Mail.config.queue)
11
+
12
+ def perform(mailer_name, action, args)
13
+ mailer = Object.const_get(mailer_name)
14
+ raise ArgumentError, "#{mailer_name} is not a GemStack::Mailer" unless mailer.is_a?(Class) && mailer < Mailer
15
+
16
+ Mailer::Delivery.new(mailer, action, args).deliver_now
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "gemstack/mail"
4
+
5
+ module GemStack
6
+ module Mail
7
+ # Test helpers (included into GemStack::TestCase by `gemstack add auth`):
8
+ #
9
+ # assert_emails(1) { post_json "/api/auth/password/forgot", { email: user.email } }
10
+ # assert_equal [user.email], last_email.to
11
+ module Testing
12
+ def before_setup
13
+ super
14
+ Mail.deliveries.clear
15
+ end
16
+
17
+ def deliveries = Mail.deliveries
18
+ def last_email = Mail.deliveries.last
19
+
20
+ def assert_emails(count, &block)
21
+ before = Mail.deliveries.size
22
+ adapter = defined?(GemStack::Jobs) && GemStack::Jobs.adapter
23
+ adapter = nil unless adapter.respond_to?(:perform_enqueued)
24
+ queued = adapter ? adapter.enqueued.map { |job| job["id"] } : []
25
+ block&.call
26
+ # deliver_later mail sent inside the block counts too.
27
+ adapter&.perform_enqueued(only: ["GemStack::Mail::DeliveryJob"], except_ids: queued)
28
+ assert_equal count, Mail.deliveries.size - before,
29
+ "Expected #{count} email(s), got #{Mail.deliveries.size - before}"
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mail"
4
+ require "erubi"
5
+ require "uri"
6
+ require "gemstack/core"
7
+
8
+ module GemStack
9
+ # Email (DECISIONS D-047).
10
+ #
11
+ # class AccountMailer < GemStack::Mailer
12
+ # default from: "Shop <hello@shop.test>"
13
+ #
14
+ # def welcome(user_id)
15
+ # @user = User.find(user_id)
16
+ # mail to: @user.email, subject: "Welcome!" # templates/account_mailer/welcome.{text,html}.erb
17
+ # end
18
+ # end
19
+ #
20
+ # AccountMailer.welcome(user.id).deliver_later # through GemStack::Jobs (pass ids)
21
+ # AccountMailer.welcome(user.id).deliver_now
22
+ module Mail
23
+ class Config < Settings
24
+ # :smtp (production), :log (development: logged and saved to tmp/mail),
25
+ # :test (captured in GemStack::Mail.deliveries), or an object with #deliver(message).
26
+ setting :delivery, default: lambda {
27
+ if GemStack.env.test? then :test
28
+ elsif GemStack.env.development? then :log
29
+ else :smtp
30
+ end
31
+ }
32
+ # smtp://user:password@smtp.example.com:587 (STARTTLS) or smtps://…:465
33
+ setting :smtp_url, default: -> { ENV.fetch("SMTP_URL", nil) }
34
+ setting :default_from, default: -> { ENV.fetch("MAIL_FROM", "#{GemStack.config.name} <no-reply@localhost>") }
35
+ setting :templates_path, default: "app/mailers/templates"
36
+ setting :preview_dir, default: "tmp/mail"
37
+ # Queue used by deliver_later.
38
+ setting :queue, default: "mailers"
39
+ end
40
+
41
+ class DeliveryError < Error; end
42
+
43
+ # Loaded on first use, also by a jobs worker resolving the queued class name.
44
+ autoload :DeliveryJob, "gemstack/mail/delivery_job"
45
+
46
+ @deliveries = []
47
+ @mutex = Mutex.new
48
+
49
+ class << self
50
+ def config = GemStack.config.mail
51
+
52
+ # Messages delivered with the :test method.
53
+ attr_reader :deliveries
54
+
55
+ def deliver(message)
56
+ method = config.delivery
57
+ case method
58
+ when :test, "test" then @mutex.synchronize { @deliveries << message }
59
+ when :log, "log" then deliver_to_log(message)
60
+ when :smtp, "smtp" then deliver_smtp(message)
61
+ else
62
+ unless method.respond_to?(:deliver)
63
+ raise ConfigurationError,
64
+ "a mail delivery method must respond to #deliver"
65
+ end
66
+
67
+ method.deliver(message)
68
+ end
69
+ GemStack.logger.info("mail.delivered", to: Array(message.to).join(","), subject: message.subject,
70
+ via: method.is_a?(Symbol) ? method : method.class.name)
71
+ message
72
+ end
73
+
74
+ def smtp_settings(url = config.smtp_url)
75
+ if url.to_s.empty?
76
+ raise ConfigurationError,
77
+ "set SMTP_URL (e.g. smtp://user:pass@smtp.example.com:587) to send mail"
78
+ end
79
+
80
+ uri = URI.parse(url)
81
+ {
82
+ address: uri.host, port: uri.port || (uri.scheme == "smtps" ? 465 : 587),
83
+ user_name: uri.user && URI.decode_www_form_component(uri.user),
84
+ password: uri.password && URI.decode_www_form_component(uri.password),
85
+ authentication: uri.user ? :plain : nil, tls: uri.scheme == "smtps",
86
+ enable_starttls_auto: uri.scheme != "smtps", open_timeout: 5, read_timeout: 10
87
+ }.compact
88
+ end
89
+
90
+ private
91
+
92
+ def deliver_smtp(message)
93
+ message.delivery_method(:smtp, smtp_settings)
94
+ message.deliver
95
+ rescue Net::SMTPError, SocketError, SystemCallError, Timeout::Error, OpenSSL::SSL::SSLError => e
96
+ raise DeliveryError, "SMTP delivery failed: #{e.class}: #{e.message}"
97
+ end
98
+
99
+ # Development: a log line plus a copy on disk (.eml, and .html to open in a browser).
100
+ def deliver_to_log(message)
101
+ dir = File.expand_path(config.preview_dir, GemStack.config.root)
102
+ FileUtils.mkdir_p(dir)
103
+ slug = message.subject.to_s.downcase.gsub(/[^a-z0-9]+/, "-").delete_prefix("-")[0, 40]
104
+ base = File.join(dir, "#{Time.now.strftime("%Y%m%d-%H%M%S-%L")}-#{slug}")
105
+ File.write("#{base}.eml", message.to_s)
106
+ html = message.html_part&.decoded
107
+ File.write("#{base}.html", html) if html
108
+ GemStack.logger.info("mail saved", to: Array(message.to).join(","), subject: message.subject,
109
+ file: "#{base}#{".html" if html}")
110
+ end
111
+ end
112
+ end
113
+
114
+ # Base class for mailers. Public instance methods are mail "actions";
115
+ # calling one on the class returns a Delivery. An action that returns
116
+ # without calling `mail` sends nothing (e.g. the user was deleted since).
117
+ class Mailer
118
+ # Rendered as the value of @variables set in the action.
119
+ class TemplateContext
120
+ def initialize(mailer)
121
+ mailer.instance_variables.each { |ivar| instance_variable_set(ivar, mailer.instance_variable_get(ivar)) }
122
+ end
123
+
124
+ def render(source, escape:) = instance_eval(Erubi::Engine.new(source, escape: escape).src)
125
+ end
126
+
127
+ # A message waiting to be delivered.
128
+ class Delivery
129
+ attr_reader :mailer_class, :action, :args
130
+
131
+ def initialize(mailer_class, action, args)
132
+ @mailer_class = mailer_class
133
+ @action = action
134
+ @args = args
135
+ end
136
+
137
+ def message
138
+ return @message if defined?(@message)
139
+
140
+ @message = mailer_class.new.build(action, args)
141
+ end
142
+
143
+ # nil when the action decided not to send (it returned without calling mail).
144
+ def deliver_now
145
+ msg = message
146
+ msg && Mail.deliver(msg)
147
+ end
148
+
149
+ # Delivers from a background job (arguments must be JSON values — pass ids).
150
+ def deliver_later(wait: nil)
151
+ require "gemstack/jobs"
152
+ job = wait ? Mail::DeliveryJob.set(wait: wait) : Mail::DeliveryJob
153
+ job.perform_later(mailer_class.name, action.to_s, args)
154
+ rescue LoadError
155
+ raise ConfigurationError, "deliver_later needs gemstack-jobs; add it to the Gemfile or use deliver_now"
156
+ end
157
+ end
158
+
159
+ class << self
160
+ def default(**headers) = defaults.merge!(headers.transform_keys(&:to_sym))
161
+ def defaults = @defaults ||= superclass.respond_to?(:defaults) ? superclass.defaults.dup : {}
162
+
163
+ def actions
164
+ (public_instance_methods(true) - Mailer.public_instance_methods(true)).map(&:to_s)
165
+ end
166
+
167
+ def respond_to_missing?(name, include_private = false) = actions.include?(name.to_s) || super
168
+
169
+ def method_missing(name, *args)
170
+ return super unless actions.include?(name.to_s)
171
+
172
+ Delivery.new(self, name.to_s, args)
173
+ end
174
+ end
175
+
176
+ def build(action, args)
177
+ @action = action
178
+ public_send(action, *args)
179
+ GemStack.logger.debug("mail skipped", mailer: self.class.name, action: action) unless @message
180
+ @message
181
+ end
182
+
183
+ # Builds the message. Bodies come from text:/html: or, when omitted, from
184
+ # templates: <templates_path>/<mailer>/<action>.text.erb and .html.erb
185
+ # (HTML templates escape <%= %> output; use <%== %> for trusted markup).
186
+ def mail(to:, subject:, text: nil, html: nil, from: nil, cc: nil, bcc: nil, reply_to: nil, template: @action)
187
+ headers = self.class.defaults
188
+ text ||= render_template(template, "text", escape: false)
189
+ html ||= render_template(template, "html", escape: true)
190
+ raise Error, "#{self.class.name}##{@action}: no body (give text:/html: or add a template)" unless text || html
191
+
192
+ @message = build_message(to: to, subject: subject, text: text, html: html,
193
+ from: from || headers[:from] || Mail.config.default_from, cc: cc, bcc: bcc,
194
+ reply_to: reply_to || headers[:reply_to])
195
+ end
196
+
197
+ private
198
+
199
+ def render_template(name, format, escape:)
200
+ path = File.join(GemStack.config.root, Mail.config.templates_path, Inflector.underscore(self.class.name),
201
+ "#{name}.#{format}.erb")
202
+ return nil unless File.file?(path)
203
+
204
+ TemplateContext.new(self).render(File.read(path), escape: escape)
205
+ end
206
+
207
+ def build_message(to:, subject:, text:, html:, from:, cc:, bcc:, reply_to:)
208
+ message = ::Mail.new
209
+ message.from = from
210
+ message.to = to
211
+ message.cc = cc if cc
212
+ message.bcc = bcc if bcc
213
+ message.reply_to = reply_to if reply_to
214
+ message.subject = subject
215
+ if text
216
+ message.text_part = ::Mail::Part.new do
217
+ body text
218
+ content_type "text/plain; charset=UTF-8"
219
+ end
220
+ end
221
+ if html
222
+ message.html_part = ::Mail::Part.new do
223
+ body html
224
+ content_type "text/html; charset=UTF-8"
225
+ end
226
+ end
227
+ message
228
+ end
229
+ end
230
+ end
231
+
232
+ GemStack::Config.namespace(:mail, GemStack::Mail::Config)
metadata ADDED
@@ -0,0 +1,93 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: gemstack-mail
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Shoaib Malik
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: erubi
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.13'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.13'
26
+ - !ruby/object:Gem::Dependency
27
+ name: gemstack-core
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - '='
31
+ - !ruby/object:Gem::Version
32
+ version: 0.1.0
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - '='
38
+ - !ruby/object:Gem::Version
39
+ version: 0.1.0
40
+ - !ruby/object:Gem::Dependency
41
+ name: mail
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '2.9'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '2.9'
54
+ email:
55
+ - gemstack26@gmail.com
56
+ executables: []
57
+ extensions: []
58
+ extra_rdoc_files: []
59
+ files:
60
+ - CHANGELOG.md
61
+ - LICENSE.txt
62
+ - README.md
63
+ - lib/gemstack/mail.rb
64
+ - lib/gemstack/mail/delivery_job.rb
65
+ - lib/gemstack/mail/testing.rb
66
+ homepage: https://github.com/gemstack-rb/gemstack
67
+ licenses:
68
+ - MIT
69
+ metadata:
70
+ rubygems_mfa_required: 'true'
71
+ source_code_uri: https://github.com/gemstack-rb/gemstack/tree/main/gems/gemstack-mail
72
+ changelog_uri: https://github.com/gemstack-rb/gemstack/blob/main/gems/gemstack-mail/CHANGELOG.md
73
+ bug_tracker_uri: https://github.com/gemstack-rb/gemstack/issues
74
+ documentation_uri: https://github.com/gemstack-rb/gemstack/tree/main/docs
75
+ rdoc_options: []
76
+ require_paths:
77
+ - lib
78
+ required_ruby_version: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '4.0'
83
+ required_rubygems_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: '0'
88
+ requirements: []
89
+ rubygems_version: 4.0.20
90
+ specification_version: 4
91
+ summary: 'GemStack mail: mailers, templates, SMTP/log/test delivery, deliver_later
92
+ via jobs'
93
+ test_files: []