monkrb 0.16.0 → 0.17.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7e6286fdc2af1003d176142f6d7b7410c1f924f78466f28ff1bd4b95709c6230
4
- data.tar.gz: 1c5030467a67e80f57e4f10ad939c06392d5d80cff0cc732692589f207173a66
3
+ metadata.gz: b983d19c7b6de8509f55f50c796d00d90c2d5d345cbe2a9e0f30e8f1349fe7be
4
+ data.tar.gz: 0fc9a16f56f69aafd1f3937268d337054399c7de34dd1a9a4986d3a6ae3e1bd5
5
5
  SHA512:
6
- metadata.gz: 204709f8e01de1f7d13e3223daff2a875979b1fd93ed5fa51663e5750f1fab6046f3ac9827712204864d2d6f50b309088f2262fa66533715b0ab486e33e330e5
7
- data.tar.gz: 727e5d900941e860ae491bff2e7042a4d9ac7be13131707435361aca990021cc8fcd4b580421bfe10b0ebbe7c12430f461629449b18f49098437fa17097d72ad
6
+ metadata.gz: 582398dd16f93f3d352ed51edfb79904a0e494c98daba3230abce233901b468a5e482d5fa97dfe29832293d79e85e340a491d5899cda327a298b120108008f1a
7
+ data.tar.gz: 1bde7799803407e6b41c52040e6fa3893a4fd8c2ebb9cca5324cfeb6a1ac291b8a5eaabd7f591204c835179985d14887b009610ebab82f650a6b3d528355f901
data/CHANGELOG.md CHANGED
@@ -4,6 +4,41 @@ All notable changes to this project are documented here. Format is loosely
4
4
  [Keep a Changelog](https://keepachangelog.com/); versions are as released
5
5
  in `lib/monk/version.rb`.
6
6
 
7
+ ## 0.17.0 - 2026-09-26
8
+
9
+ ### Added
10
+
11
+ - `Monk::Mail` (`require "monk/mail"`): text and/or HTML email, sent
12
+ synchronously from inside whichever worker Ractor serves the request.
13
+ The `mail` gem, and so Action Mailer, raises `Ractor::IsolationError`
14
+ there, so Monk builds the MIME itself: multipart/alternative, UTF-8,
15
+ base64 bodies, RFC 2047 headers, and header-injection checks on every
16
+ address and subject. `Monk::Mail.configure(url:, from:)` takes one
17
+ `MAIL_URL` (`smtp://`, `smtps://` or `log://`), parsed and sealed at
18
+ boot; an unset one means `log://` in development and a boot error
19
+ elsewhere. `Monk::Mail.deliver(to:, subject:, text:, html:, ...)` sends.
20
+ SMTP needs the app's own `gem "net-smtp"`. No attachments, inline
21
+ images or bulk sending. See `docs/guides/mail.md` and ADR 0012, which
22
+ reverses the earlier "email stays outside the framework" decision.
23
+ - `Monk::Mail.render(template, **locals)`: renders a view (e.g.
24
+ `views/mail/magic_link.erb`) to the `html:` String, locals only, no page
25
+ layout, callable from any worker Ractor after boot.
26
+
27
+ - `monk new --mail`: scaffolds `Monk::Mail` on its own, no Postgres needed:
28
+ `config/mail.rb` (required by `config.ru`), `gem "net-smtp"`, `MAIL_FROM`
29
+ in the env files, `MAIL_URL=log://` in `.env.test`, and a SETUP.md whose
30
+ test helper loads `.env.test` before `config/mail`.
31
+
32
+ ### Changed
33
+
34
+ - **`monk new --auth` now sends the magic link by email**, and implies
35
+ `--mail` the same way it implies `--postgres`. On top of `--mail` it
36
+ adds `views/mail/magic_link.erb` and a working `AppMailer::DELIVER` wired into
37
+ `config/auth.rb`'s `deliver:`. `.env.test` sets `MAIL_URL=log://`; in
38
+ development an unset `MAIL_URL` prints the message to the console
39
+ (`log_dev_link`'s line and QR code still appear); elsewhere it must be
40
+ set, or the boot fails.
41
+
7
42
  ## 0.16.0 - 2026-09-24
8
43
 
9
44
  ### Added
data/README.md CHANGED
@@ -60,6 +60,7 @@ Everything beyond the core is opt-in (`require "monk"` alone loads none of it).
60
60
  | Persistence | `Monk::Persistence::Pg`: raw `pg`, per-Ractor connections, hash-based `Model` | `require "monk/persistence/pg"` (+ `.../pg/model`); needs the `pg` gem | [`persistence.md`](docs/guides/persistence.md) |
61
61
  | Migrations | plain `.sql` up/down pairs, `Migrator` | `require "monk/persistence/pg/migrator"`; needs the `pg` gem | [`migrations.md`](docs/guides/migrations.md) |
62
62
  | Auth and sessions | `Monk::Auth`: passwordless tokens, Bearer or cookie + CSRF | `require "monk/auth"`; needs the `pg` gem and a registered Postgres connection | [`auth.md`](docs/guides/auth.md) |
63
+ | Email | `Monk::Mail`: text/HTML email over SMTP or a local relay, one `MAIL_URL`, sent from any worker Ractor | `require "monk/mail"`; SMTP needs the `net-smtp` gem | [`mail.md`](docs/guides/mail.md) |
63
64
  | WebSocket | `Monk::WebSocket`: RFC 6455 server as its own process, Redis or Postgres fan-out | `require "monk/websocket"`; fan-out: `require "monk/websocket/redis_fanout"` (+ the `redis` gem) or `require "monk/websocket/pg_fanout"` (+ the `pg` gem) | [`websocket.md`](docs/guides/websocket.md) |
64
65
  | Live updates | `Monk::Live`: server-rendered HTML patches pushed to open tabs | `require "monk/live"`; needs the WebSocket server and, across processes, Redis or Postgres | [`live.md`](docs/guides/live.md) |
65
66
  | Scaffolding | `monk new` and its flags; retrofitting Postgres, Auth or Redis | — (the `monk` command; flags `--postgres`, `--auth`, `--redis`, `--live`) | [`scaffolding.md`](docs/guides/scaffolding.md) |
data/exe/monk CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env ruby
2
2
  require_relative "../lib/monk/scaffold"
3
3
 
4
- USAGE = "Usage: monk new APP_NAME [--postgres] [--auth] [--redis] [--live]".freeze
4
+ USAGE = "Usage: monk new APP_NAME [--postgres] [--auth] [--mail] [--redis] [--live]".freeze
5
5
 
6
6
  HELP = <<~TEXT.freeze
7
7
  #{USAGE}
@@ -41,7 +41,18 @@ HELP = <<~TEXT.freeze
41
41
  requires config/auth instead of config/persistence
42
42
  (config/auth.rb itself requires persistence), and
43
43
  .env/.env.test/.env.example get an AUTH_SECRET line.
44
- --redis Add the redis gem to the Gemfile, for
44
+ Also implies --mail, below: config/auth.rb sends the
45
+ magic link through Monk::Mail, with an HTML part in
46
+ views/mail/magic_link.erb.
47
+ --mail Also scaffold Monk::Mail (text/HTML email over SMTP),
48
+ no Postgres needed: config/mail.rb, required by
49
+ config.ru, plus gem "net-smtp" in the Gemfile.
50
+ .env/.env.test/.env.example get MAIL_FROM, and
51
+ MAIL_URL where it matters: log:// in .env.test, an
52
+ smtp:// placeholder in .env.example. Unset in
53
+ development, MAIL_URL means log:// (messages printed,
54
+ not sent); anywhere else it must be set.
55
+ --redis Add the redis gem to the Gemfile, for
45
56
  bin/websocket_server's cross-process fan-out. Fully
46
57
  independent -- doesn't imply, and isn't implied by,
47
58
  --postgres or --auth. Writes/extends .env and
@@ -109,15 +120,19 @@ when "new"
109
120
  usage_error! if app_name.nil?
110
121
 
111
122
  auth = rest.include?("--auth")
123
+ mail = rest.include?("--mail") || auth
112
124
  redis = rest.include?("--redis")
113
125
  live = rest.include?("--live")
114
- Monk::Scaffold.new(app_name, postgres: rest.include?("--postgres"), auth: auth, redis: redis, live: live).write!
126
+ Monk::Scaffold.new(
127
+ app_name, postgres: rest.include?("--postgres"), auth: auth, mail: mail, redis: redis, live: live,
128
+ ).write!
115
129
 
116
130
  puts "Created #{app_name}. Next steps:"
117
131
  puts " cd #{app_name}"
118
132
  puts " bundle install"
119
133
  puts " see SETUP.md for the full dev-then-test walkthrough"
120
134
  puts " change the placeholder AUTH_SECRET in .env/.env.test before relying on it" if auth
135
+ puts " set MAIL_URL and MAIL_FROM outside development (see config/mail.rb)" if mail
121
136
  if live
122
137
  if redis
123
138
  puts " Monk::Live needs Redis running, bin/server and bin/websocket_server (see SETUP.md)"
data/lib/monk/auth.rb CHANGED
@@ -90,9 +90,9 @@ module Monk
90
90
  # (see below), and raises everywhere else: a silent no-op here would
91
91
  # mean an app that forgot to wire delivery finds out only when a
92
92
  # user reports never receiving their link, in production, which is
93
- # the wrong place to fail (docs/design/auth-sessions.md's "Email
94
- # delivery stays outside the framework" -- Monk still needs to know
95
- # it was told to send *something*).
93
+ # the wrong place to fail. deliver: stays a plain callable even now
94
+ # that Monk::Mail exists (docs/adr/0012-minimal-built-in-mailer.md):
95
+ # it's usually a one-line Monk::Mail.deliver, but can be any channel.
96
96
  #
97
97
  # `link` is the app's own job to build (Monk doesn't own routing, so
98
98
  # it can't know the callback path) -- from a trusted origin, e.g.
@@ -0,0 +1,41 @@
1
+ module Monk
2
+ module Mail
3
+ # Just enough address handling for what Monk::Mail sends: split
4
+ # "Name <addr>" into its parts, and render one back into a header
5
+ # value. Not an RFC 5322 parser -- groups, comments and quoted local
6
+ # parts are out of scope (docs/adr/0012-minimal-built-in-mailer.md).
7
+ module Address
8
+ ANGLE = /\A\s*(.*?)\s*<([^<>]*)>\s*\z/
9
+ # A display name made only of these goes out as-is; anything else
10
+ # ASCII gets quoted, so a comma in "Doe, John" can't split one
11
+ # recipient into two.
12
+ PLAIN_PHRASE = %r{\A[A-Za-z0-9!#$%&'*+\-/=?^_`{|}~ ]+\z}
13
+
14
+ module_function
15
+
16
+ # "App <a@app.test>" -> ["App", "a@app.test"]; "a@app.test" -> [nil, "a@app.test"]
17
+ def split(value)
18
+ match = ANGLE.match(value)
19
+ return [nil, value.strip] unless match
20
+
21
+ name = match[1]
22
+ [name.empty? ? nil : name, match[2].strip]
23
+ end
24
+
25
+ def bare(value) = split(value).last
26
+
27
+ def header(value)
28
+ name, address = split(value)
29
+ name ? "#{phrase(name)} <#{address}>" : address
30
+ end
31
+
32
+ def phrase(name)
33
+ return name if name.length >= 2 && name.start_with?('"') && name.end_with?('"')
34
+ return MIME.encode_words(name) unless name.ascii_only?
35
+ return name if name.match?(PLAIN_PHRASE)
36
+
37
+ %("#{name.gsub(/["\\]/) { |char| "\\#{char}" }}")
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,28 @@
1
+ module Monk
2
+ module Mail
3
+ # An ArgumentError: it's always the caller's input that's wrong, never
4
+ # the transport or the network.
5
+ class InvalidMessageError < ArgumentError
6
+ end
7
+
8
+ class InvalidMailUrlError < StandardError
9
+ end
10
+
11
+ class MissingMailUrlError < StandardError
12
+ end
13
+
14
+ class MissingDependencyError < StandardError
15
+ end
16
+
17
+ class ViewsNotFrozenError < StandardError
18
+ end
19
+
20
+ class NotConfiguredError < StandardError
21
+ end
22
+
23
+ # A send that failed on the way out -- network, TLS, or the server
24
+ # refusing it. The transport's own exception is the #cause.
25
+ class DeliveryError < StandardError
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,66 @@
1
+ module Monk
2
+ module Mail
3
+ # One email, as a frozen value: built per request inside whichever
4
+ # worker Ractor serves it, then handed to a transport without a copy
5
+ # (docs/adr/0012-minimal-built-in-mailer.md). Deeply frozen on
6
+ # construction, so it's Ractor.shareable? even when the caller passed
7
+ # mutable Strings or Arrays -- and later mutating those can't change it.
8
+ #
9
+ # Addresses are kept as given ("Name <addr>" or a bare "addr"); nothing
10
+ # here parses them. `to` is always an Array, however it was passed.
11
+ Message = Data.define(:from, :to, :subject, :text, :html, :reply_to) do
12
+ def initialize(from:, to:, subject:, text: nil, html: nil, reply_to: nil)
13
+ to = Array(to) unless to.nil?
14
+ to&.each { |address| Fields.header!(:to, address) }
15
+ raise InvalidMessageError, "to: needs at least one recipient" if to.nil? || to.empty?
16
+
17
+ Fields.header!(:from, from)
18
+ Fields.header!(:subject, subject)
19
+ Fields.header!(:reply_to, reply_to) unless reply_to.nil?
20
+ Fields.body!(:text, text)
21
+ Fields.body!(:html, html)
22
+ raise InvalidMessageError, "a message needs a text: or html: body (or both)" if text.nil? && html.nil?
23
+
24
+ super(**Ractor.make_shareable(
25
+ { from: from, to: to, subject: subject, text: text, html: html, reply_to: reply_to },
26
+ copy: true,
27
+ ))
28
+ end
29
+
30
+ # RFC 5322 wire format, ready for SMTP DATA or a raw-MIME API. date:,
31
+ # message_id: and boundary: are generated when omitted; tests pin them.
32
+ def to_mime(**) = MIME.build(self, **)
33
+
34
+ # The bare addresses, for the SMTP envelope (MAIL FROM / RCPT TO).
35
+ def envelope_from = Address.bare(from)
36
+ def envelope_to = to.map { |address| Address.bare(address) }
37
+ end
38
+
39
+ # Field checks shared by Message and Monk::Mail.configure(from:).
40
+ module Fields
41
+ module_function
42
+
43
+ # A CR or LF in a header value would let whoever controls it (say, a
44
+ # user typing their email into a login form) inject headers of their
45
+ # own -- a Bcc: to anyone. Rejected outright rather than stripped:
46
+ # silently rewriting a recipient is worse than refusing it.
47
+ def header!(field, value)
48
+ string!(field, value)
49
+ raise InvalidMessageError, "#{field}: can't be blank" if value.strip.empty?
50
+ return unless value.match?(/[\r\n]/)
51
+
52
+ raise InvalidMessageError, "#{field}: can't contain a line break (#{value.inspect})"
53
+ end
54
+
55
+ def body!(field, value)
56
+ string!(field, value) unless value.nil?
57
+ end
58
+
59
+ def string!(field, value)
60
+ return if value.is_a?(String)
61
+
62
+ raise InvalidMessageError, "#{field}: must be a String, got #{value.inspect}"
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,124 @@
1
+ require "securerandom"
2
+
3
+ module Monk
4
+ module Mail
5
+ # Renders a Message to RFC 5322 wire format -- what the `mail` gem
6
+ # would do, cut down to Monk::Mail's scope: a text part, an HTML part,
7
+ # or both as multipart/alternative. Bodies are UTF-8 and always base64
8
+ # (never 7bit/quoted-printable), so any content survives any relay and
9
+ # no line ever gets near the 998-octet limit. Header values that
10
+ # aren't plain ASCII are RFC 2047 encoded words. Plain module
11
+ # functions over frozen input, so it runs in any Ractor.
12
+ module MIME
13
+ CRLF = "\r\n".freeze
14
+ MAX_LINE = 78
15
+ # 42 bytes base64-encode to 56 chars; with the "=?UTF-8?B?" / "?="
16
+ # wrapper that's a 68-char encoded word, under RFC 2047's 75, and
17
+ # "Subject: " plus one of them still fits a 78-char line.
18
+ WORD_BYTES = 42
19
+
20
+ module_function
21
+
22
+ def build(message, date: Time.now, message_id: nil, boundary: nil)
23
+ message_id ||= "<#{SecureRandom.uuid}@#{domain(message.envelope_from)}>"
24
+ headers = [
25
+ "From: #{address_list([message.from], "From")}",
26
+ "To: #{address_list(message.to, "To")}",
27
+ ("Reply-To: #{address_list([message.reply_to], "Reply-To")}" if message.reply_to),
28
+ "Subject: #{unstructured(message.subject, "Subject")}",
29
+ "Date: #{date.strftime("%a, %d %b %Y %H:%M:%S %z")}",
30
+ "Message-ID: #{message_id}",
31
+ "MIME-Version: 1.0",
32
+ ].compact
33
+
34
+ parts = [
35
+ (["text/plain", message.text] if message.text),
36
+ (["text/html", message.html] if message.html),
37
+ ].compact
38
+
39
+ if parts.size == 1
40
+ (headers + part(*parts.first)).join(CRLF)
41
+ else
42
+ boundary ||= "monk-#{SecureRandom.hex(16)}"
43
+ head = headers + [%(Content-Type: multipart/alternative; boundary="#{boundary}"), "", ""]
44
+ body = parts.map { |type, content| "--#{boundary}#{CRLF}#{part(type, content).join(CRLF)}" }
45
+ head.join(CRLF) + body.join + "--#{boundary}--#{CRLF}"
46
+ end
47
+ end
48
+
49
+ # A part's own headers, a blank line, then its base64 body (which
50
+ # ends in CRLF).
51
+ def part(type, content)
52
+ body = utf8(content, type).gsub(/\r?\n/, CRLF)
53
+ [
54
+ "Content-Type: #{type}; charset=UTF-8",
55
+ "Content-Transfer-Encoding: base64",
56
+ "",
57
+ [body].pack("m").gsub("\n", CRLF),
58
+ ]
59
+ end
60
+
61
+ def address_list(addresses, name)
62
+ rendered = addresses.map { |address| Address.header(utf8(address, name)) }
63
+ one_line = rendered.join(", ")
64
+ name.length + 2 + one_line.length <= MAX_LINE ? one_line : rendered.join(",#{CRLF} ")
65
+ end
66
+
67
+ # ASCII stays readable (folded at spaces when long); anything else,
68
+ # or anything that could be mistaken for an encoded word, is encoded.
69
+ def unstructured(value, name)
70
+ value = utf8(value, name)
71
+ return encode_words(value) if !value.ascii_only? || value.include?("=?")
72
+
73
+ fold(value, name.length + 2)
74
+ end
75
+
76
+ # RFC 2047 "B" encoded words, split so none exceeds WORD_BYTES of
77
+ # input -- and never inside a multibyte character, which would leave
78
+ # both halves undecodable. Joined by folding whitespace, which
79
+ # decoders drop between adjacent encoded words.
80
+ def encode_words(value)
81
+ chunks = value.each_char.with_object([+""]) do |char, acc|
82
+ acc << +"" if acc.last.bytesize + char.bytesize > WORD_BYTES
83
+ acc.last << char
84
+ end
85
+ chunks.map { |chunk| "=?UTF-8?B?#{[chunk].pack("m0")}?=" }.join("#{CRLF} ")
86
+ end
87
+
88
+ # Greedy wrap at spaces; a continuation line starts with one space.
89
+ # A value that already fits is returned untouched, spacing and all.
90
+ def fold(value, offset)
91
+ return value if offset + value.length <= MAX_LINE
92
+
93
+ lines = []
94
+ current = nil
95
+ value.split.each do |word|
96
+ width = lines.empty? ? offset : 1
97
+ if current && width + current.length + 1 + word.length > MAX_LINE
98
+ lines << current
99
+ current = word
100
+ else
101
+ current = current ? "#{current} #{word}" : word
102
+ end
103
+ end
104
+ lines << current if current
105
+ lines.join("#{CRLF} ")
106
+ end
107
+
108
+ def domain(address)
109
+ address.include?("@") ? address.split("@").last : "localhost"
110
+ end
111
+
112
+ # Every piece of text goes out as UTF-8. A binary String is assumed
113
+ # to be UTF-8 bytes; any other encoding is transcoded.
114
+ def utf8(value, name)
115
+ string = value.encoding == Encoding::BINARY ? value.dup.force_encoding(Encoding::UTF_8) : value.encode(Encoding::UTF_8)
116
+ return string if string.valid_encoding?
117
+
118
+ raise InvalidMessageError, "#{name} isn't valid UTF-8"
119
+ rescue EncodingError
120
+ raise InvalidMessageError, "#{name} can't be converted to UTF-8"
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,115 @@
1
+ module Monk
2
+ module Mail
3
+ # Where a Message goes. Each transport is a frozen Data value holding
4
+ # only its settings -- never a connection or client object -- so it
5
+ # can sit in Monk::Mail's boot-frozen config and be read from any
6
+ # worker Ractor; the client is built fresh on every send, inside the
7
+ # Ractor doing the sending, the same pattern as PG::Connection
8
+ # (docs/adr/0012-minimal-built-in-mailer.md).
9
+ module Transports
10
+ # starttls: :always (fail if the server doesn't offer it), :auto (use
11
+ # it if offered) or :never. tls: true is implicit TLS from the first
12
+ # byte (smtps://, usually port 465), where STARTTLS doesn't apply.
13
+ #
14
+ # net-smtp is the app's dependency, not Monk's (a bundled gem since
15
+ # Ruby 3.1, like pg/redis are opt-in): required when an smtp:// URL
16
+ # is configured, so a missing gem fails the boot, not the first send.
17
+ SMTP = Data.define(:host, :port, :user, :password, :tls, :starttls, :open_timeout, :read_timeout) do
18
+ def self.require_library!
19
+ require "net/smtp"
20
+ rescue LoadError
21
+ raise MissingDependencyError,
22
+ "MAIL_URL is smtp:// or smtps://, which needs the net-smtp gem -- a bundled, not default, " \
23
+ "gem since Ruby 3.1. Add `gem \"net-smtp\"` to your Gemfile."
24
+ end
25
+
26
+ # Called at boot, in the main Ractor, from Monk::Mail.freeze_registry!.
27
+ # Net::SMTP keeps its AUTH mechanisms ({PLAIN: AuthPlain, ...}) in a
28
+ # class-level Hash, filled once when net/smtp is required; unfrozen,
29
+ # a worker Ractor reading it raises Ractor::IsolationError on every
30
+ # authenticated send. Sealing it is the same move as
31
+ # Monk::Auth.freeze_rqrcode!. The cost: an auth mechanism registered
32
+ # after boot would raise FrozenError -- nothing in net-smtp does that.
33
+ def self.prepare!
34
+ require_library!
35
+ Ractor.make_shareable(Net::SMTP::Authenticator.auth_classes)
36
+ end
37
+
38
+ def initialize(host:, port:, user: nil, password: nil, tls: false, starttls: :auto, open_timeout: 5,
39
+ read_timeout: 10)
40
+ super
41
+ end
42
+
43
+ # One connection per message, built here, inside whichever Ractor
44
+ # is sending. Any network, TLS or SMTP-level failure becomes a
45
+ # Monk::Mail::DeliveryError (the original is its #cause), so a
46
+ # caller rescues one class whatever went wrong.
47
+ def deliver(message)
48
+ smtp = Net::SMTP.new(host, port, tls: tls, starttls: starttls == :never ? false : starttls)
49
+ smtp.open_timeout = open_timeout
50
+ smtp.read_timeout = read_timeout
51
+ smtp.start(helo: MIME.domain(message.envelope_from), user: user, secret: password) do |session|
52
+ session.send_message(message.to_mime, message.envelope_from, *message.envelope_to)
53
+ end
54
+ message
55
+ rescue Net::SMTPError, IOError, SystemCallError, SocketError, Timeout::Error, OpenSSL::SSL::SSLError => e
56
+ raise DeliveryError, "SMTP delivery via #{host}:#{port} failed: #{e.class}: #{e.message.strip}"
57
+ end
58
+
59
+ # Never print the password -- a transport ends up in logs and
60
+ # exception messages more easily than anyone intends.
61
+ def inspect
62
+ "#<#{self.class.name} #{tls ? "smtps" : "smtp"}://#{"#{user}:***@" if user}#{host}:#{port} " \
63
+ "starttls=#{starttls}>"
64
+ end
65
+ alias_method :to_s, :inspect
66
+ end
67
+
68
+ # Writes each message to Monk::Log instead of sending it: the
69
+ # development default when MAIL_URL is unset, and an explicit
70
+ # log:// anywhere else. One line per message in log/<env>.log (the
71
+ # text body escaped with #inspect so it stays one line; the HTML
72
+ # only by size), plus, in development only, the whole readable
73
+ # message on $stdout -- where a developer is looking, and where a
74
+ # magic link gets clicked. Same stdout posture as Base#log_request
75
+ # and Monk::Auth.log_dev_link.
76
+ Log = Data.define do
77
+ def deliver(message)
78
+ Monk::Log.info("[mail] #{summary(message)}")
79
+ if Monk.env.development?
80
+ $stdout.puts(readable(message))
81
+ $stdout.flush
82
+ end
83
+ message
84
+ end
85
+
86
+ private
87
+
88
+ def summary(message)
89
+ fields = [
90
+ "from=#{message.from.inspect}",
91
+ "to=#{message.to.join(", ").inspect}",
92
+ ("reply_to=#{message.reply_to.inspect}" if message.reply_to),
93
+ "subject=#{message.subject.inspect}",
94
+ ("text=#{message.text.inspect}" if message.text),
95
+ ("html=#{message.html.bytesize} bytes" if message.html),
96
+ ]
97
+ fields.compact.join(" ")
98
+ end
99
+
100
+ def readable(message)
101
+ lines = [
102
+ "[mail] ----------------------------------------",
103
+ "From: #{message.from}",
104
+ "To: #{message.to.join(", ")}",
105
+ ("Reply-To: #{message.reply_to}" if message.reply_to),
106
+ "Subject: #{message.subject}",
107
+ ]
108
+ lines += ["", "--- text ---", message.text] if message.text
109
+ lines += ["", "--- html ---", message.html] if message.html
110
+ (lines.compact + ["[mail] ----------------------------------------"]).join("\n")
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,76 @@
1
+ require "uri"
2
+
3
+ module Monk
4
+ module Mail
5
+ # MAIL_URL -> a transport value. Runs once, from Monk::Mail.configure
6
+ # in the main Ractor at boot, so a malformed URL fails the boot rather
7
+ # than the first send (docs/adr/0003-boot-time-fail-fast-shareability.md's
8
+ # posture). Error messages redact the password: the URL is a secret.
9
+ module URL
10
+ SUPPORTED = "smtp://, smtps://, log://".freeze
11
+ STARTTLS_MODES = %w[auto always never].freeze
12
+
13
+ module_function
14
+
15
+ def parse(url)
16
+ uri = URI.parse(url)
17
+ case uri.scheme&.downcase
18
+ when "log" then Transports::Log.new
19
+ when "smtp" then smtp(uri, url, tls: false)
20
+ when "smtps" then smtp(uri, url, tls: true)
21
+ else invalid!(url, "unsupported scheme #{uri.scheme.inspect}; supported: #{SUPPORTED}")
22
+ end
23
+ rescue URI::InvalidURIError
24
+ invalid!(url, "not a valid URL; expected e.g. smtp://user:pass@smtp.example.com:587")
25
+ end
26
+
27
+ def smtp(uri, url, tls:)
28
+ invalid!(url, "missing host") if uri.host.nil? || uri.host.empty?
29
+
30
+ Transports::SMTP.require_library!
31
+ user = decode(uri.user)
32
+ Transports::SMTP.new(
33
+ host: uri.host,
34
+ port: uri.port || (tls ? 465 : 587),
35
+ user: user,
36
+ password: decode(uri.password),
37
+ tls: tls,
38
+ starttls: starttls(uri, url, tls: tls, credentials: !user.nil?),
39
+ )
40
+ end
41
+
42
+ # Default :always with credentials (a stripped STARTTLS would
43
+ # otherwise send the password in clear), :auto without (a local
44
+ # relay usually has no TLS set up).
45
+ def starttls(uri, url, tls:, credentials:)
46
+ options = URI.decode_www_form(uri.query || "").to_h
47
+ unknown = options.keys - ["starttls"]
48
+ invalid!(url, "unknown option(s) #{unknown.join(", ")}; the only one is starttls=") if unknown.any?
49
+
50
+ mode = options["starttls"]
51
+ if tls
52
+ invalid!(url, "starttls= doesn't apply to smtps://, which is TLS from the start") if mode
53
+ return :never
54
+ end
55
+ return credentials ? :always : :auto if mode.nil?
56
+
57
+ invalid!(url, "starttls= must be one of #{STARTTLS_MODES.join(", ")}") unless STARTTLS_MODES.include?(mode)
58
+ mode.to_sym
59
+ end
60
+
61
+ def decode(component)
62
+ return nil if component.nil? || component.empty?
63
+
64
+ URI.decode_uri_component(component)
65
+ end
66
+
67
+ def invalid!(url, reason)
68
+ raise InvalidMailUrlError, "MAIL_URL #{redact(url).inspect}: #{reason}"
69
+ end
70
+
71
+ def redact(url)
72
+ url.to_s.sub(%r{(://[^:/@]*:)[^@/]*@}, '\1***@')
73
+ end
74
+ end
75
+ end
76
+ end
data/lib/monk/mail.rb ADDED
@@ -0,0 +1,121 @@
1
+ require_relative "freeze_hooks"
2
+ require_relative "environment"
3
+ require_relative "log"
4
+ require_relative "views"
5
+ require_relative "context"
6
+ require_relative "mail/errors"
7
+ require_relative "mail/address"
8
+ require_relative "mail/mime"
9
+ require_relative "mail/message"
10
+ require_relative "mail/transports"
11
+ require_relative "mail/url"
12
+
13
+ module Monk
14
+ # Sending email. Opt-in: require "monk/mail" explicitly -- `require
15
+ # "monk"` alone does not load this. Text and/or HTML messages only, MIME
16
+ # built by Monk itself, since the `mail` gem can't run inside a worker
17
+ # Ractor (docs/adr/0012-minimal-built-in-mailer.md).
18
+ module Mail
19
+ class << self
20
+ # Typically, in config/mail.rb:
21
+ #
22
+ # Monk::Mail.configure(url: ENV["MAIL_URL"], from: ENV["MAIL_FROM"])
23
+ #
24
+ # url: picks the transport (smtp://, smtps://, log://) and is parsed
25
+ # here, so a bad one fails the boot. Unset or empty means log:// in
26
+ # development and raises everywhere else -- same posture as
27
+ # Monk::Auth.deliver_link: an app that forgot to wire mail should
28
+ # find out at boot, not when a user never gets their login link.
29
+ # from: is the default sender, optional since each send can pass its own.
30
+ def configure(url:, from: nil)
31
+ Fields.header!(:from, from) unless from.nil?
32
+
33
+ @config = { transport: transport_for(url), from: from }
34
+ end
35
+
36
+ attr_reader :config
37
+
38
+ # Builds a Message and sends it through the configured transport,
39
+ # synchronously -- the calling worker Ractor waits for the send
40
+ # (docs/adr/0012-minimal-built-in-mailer.md accepts that cost; a
41
+ # local relay or a fast HTTPS provider keeps it short). Returns the
42
+ # Message. Invalid input raises InvalidMessageError before anything
43
+ # is sent; a failed send raises DeliveryError.
44
+ #
45
+ # Monk::Mail.deliver(to: email, subject: "Your login link", text: "Log in: #{link}")
46
+ def deliver(to:, subject:, text: nil, html: nil, reply_to: nil, from: nil)
47
+ raise NotConfiguredError, "call Monk::Mail.configure(url:, from:) before Monk::Mail.deliver" unless config
48
+
49
+ from ||= config[:from]
50
+ if from.nil?
51
+ raise InvalidMessageError, "no from: given, and Monk::Mail.configure has no default from: (MAIL_FROM)"
52
+ end
53
+
54
+ message = Message.new(from: from, to: to, subject: subject, text: text, html: html, reply_to: reply_to)
55
+ config[:transport].deliver(message)
56
+ end
57
+
58
+ # Renders a Monk::Views template to an HTML String for deliver's
59
+ # html: -- e.g. views/mail/magic_link.erb:
60
+ #
61
+ # html: Monk::Mail.render("mail/magic_link", link: link)
62
+ #
63
+ # The same detached-Context render Monk::Live::Renderer does: no
64
+ # request behind it, so the template sees only its locals, never
65
+ # params or the session. The app's page layout is skipped (layout:
66
+ # false); pass layout: "mail/layout" for an email-specific one.
67
+ # `<%= %>` escapes as usual, which is right for HTML and wrong for a
68
+ # plain-text body -- build text: as a Ruby String instead.
69
+ def render(template, layout: false, **locals)
70
+ ensure_views_frozen!
71
+ String.new(Monk::Context.new({}).render(template, layout: layout, **locals)).freeze
72
+ end
73
+
74
+ # Called from Base#freeze! (Seam B), via Monk.freeze_hooks. Freezes
75
+ # the value, not the module, same as Monk::Auth.freeze_registry! --
76
+ # an unfrozen config Hash can't be read from a worker Ractor at all.
77
+ def freeze_registry!
78
+ transport = @config&.fetch(:transport)
79
+ transport.class.prepare! if transport.class.respond_to?(:prepare!)
80
+ @config = Ractor.make_shareable(@config)
81
+ end
82
+
83
+ # Test-only: drops the current config. Not part of the app-facing API.
84
+ def reset!
85
+ @config = nil
86
+ end
87
+
88
+ private
89
+
90
+ # Unfrozen, a render from a worker Ractor dies with an opaque
91
+ # Ractor::IsolationError inside Monk::Views, and one from the main
92
+ # Ractor works by accident and then fails in production -- so both
93
+ # fail here, naming the fix (same check as Monk::Live::Renderer).
94
+ def ensure_views_frozen!
95
+ return if Ractor.shareable?(Monk::Views.registry)
96
+
97
+ raise_views_not_frozen
98
+ rescue Ractor::IsolationError
99
+ raise_views_not_frozen
100
+ end
101
+
102
+ def raise_views_not_frozen
103
+ raise ViewsNotFrozenError,
104
+ "Monk::Mail.render can't render before views are compiled and frozen -- call Monk.boot(app) " \
105
+ "(or Monk.freeze!) in the main Ractor first"
106
+ end
107
+
108
+ def transport_for(url)
109
+ return URL.parse(url) unless url.nil? || url.empty?
110
+ return Transports::Log.new if Monk.env.development?
111
+
112
+ raise MissingMailUrlError,
113
+ "Monk::Mail.configure(url:) is empty and this isn't development (MONK_ENV=#{Monk.env}) -- " \
114
+ "no email would ever be sent. Set MAIL_URL, e.g. smtp://user:pass@smtp.example.com:587, " \
115
+ "or log:// if logging messages instead of sending them is truly intended here."
116
+ end
117
+ end
118
+
119
+ Monk.freeze_hooks << self
120
+ end
121
+ end
data/lib/monk/scaffold.rb CHANGED
@@ -45,6 +45,15 @@ module Monk
45
45
  "config/auth.rb" => "auth/config/auth.rb",
46
46
  "db/migrate/00000000000001_create_auth_tables.up.sql" => "auth/db/migrate/00000000000001_create_auth_tables.up.sql",
47
47
  "db/migrate/00000000000001_create_auth_tables.down.sql" => "auth/db/migrate/00000000000001_create_auth_tables.down.sql",
48
+ # The magic link's HTML part -- config/auth.rb's AppMailer::DELIVER
49
+ # renders it and sends through Monk::Mail (MAIL_FILES, implied by --auth).
50
+ "views/mail/magic_link.erb" => "auth/views/mail/magic_link.erb",
51
+ }.freeze
52
+
53
+ # --mail, or implied by --auth: Monk::Mail's config. Its Gemfile line
54
+ # (net-smtp) and MAIL_* env vars are added by #write! / #write_env_files!.
55
+ MAIL_FILES = {
56
+ "config/mail.rb" => "mail/config/mail.rb",
48
57
  }.freeze
49
58
 
50
59
  # --live: Monk::Live's demo (a counter whose open tabs update when
@@ -107,10 +116,14 @@ module Monk
107
116
  # implies --postgres) picks PgFanout; --live with neither raises.
108
117
  # See docs/design/live-pg-fanout.md / docs/history/plan-live-pg-fanout.md
109
118
  # for the design this decision comes from.
110
- def initialize(dir, postgres: false, auth: false, redis: false, live: false)
119
+ #
120
+ # mail: Monk::Mail on its own, no Postgres needed. auth: implies it, the
121
+ # same way it implies postgres: a magic link has to reach someone.
122
+ def initialize(dir, postgres: false, auth: false, redis: false, live: false, mail: false)
111
123
  @dir = dir
112
124
  @auth = auth
113
125
  @postgres = postgres || auth
126
+ @mail = mail || auth
114
127
  @live = live
115
128
 
116
129
  if @live
@@ -145,15 +158,20 @@ module Monk
145
158
  POSTGRES_FILES.each { |relative, template| write_file(relative, template, executable: EXECUTABLE_FILES.include?(relative)) }
146
159
  FileUtils.mkdir_p(File.join(@dir, "db/migrate"))
147
160
  append_gemfile_extra("postgres/Gemfile.extra")
148
- wire_config_ru!
149
161
  end
150
162
 
163
+ if @mail
164
+ MAIL_FILES.each { |relative, template| write_file(relative, template) }
165
+ append_gemfile_extra("mail/Gemfile.extra")
166
+ end
167
+
168
+ wire_config_ru! if @postgres || @mail
151
169
  append_gemfile_extra("redis/Gemfile.extra") if @redis
152
170
 
153
- # --postgres and --redis are the two flags with anything worth
154
- # putting in an env file (a database, a REDIS_URL) -- the base
155
- # skeleton alone has nothing to configure this way.
156
- if @postgres || @redis
171
+ # --postgres, --redis and --mail are the flags with anything worth
172
+ # putting in an env file (a database, a REDIS_URL, MAIL_*) -- the
173
+ # base skeleton alone has nothing to configure this way.
174
+ if @postgres || @redis || @mail
157
175
  write_env_files!
158
176
  uncomment_dotenv!
159
177
  end
@@ -224,12 +242,20 @@ module Monk
224
242
  def wire_config_ru!
225
243
  path = File.join(@dir, "config.ru")
226
244
  settings_require = %(require_relative "config/settings"\n)
227
- target = @auth ? "config/auth" : "config/persistence" # config/auth.rb itself require_relative "persistence"
245
+ requires = +""
246
+ if @postgres
247
+ target = @auth ? "config/auth" : "config/persistence" # config/auth.rb itself require_relative "persistence"
248
+ requires << "require_relative \"#{target}\"\n"
249
+ end
250
+ # config/mail.rb from config.ru only, never from config/auth.rb:
251
+ # bin/websocket_server loads config/auth.rb too and never sends mail,
252
+ # so it shouldn't need MAIL_URL to boot.
253
+ requires << "require_relative \"config/mail\"\n" if @mail
228
254
 
229
255
  content = File.read(path)
230
256
  raise "config.ru wiring failed: #{settings_require.inspect} not found" unless content.include?(settings_require)
231
257
 
232
- File.write(path, content.sub(settings_require, "#{settings_require}require_relative \"#{target}\"\n"))
258
+ File.write(path, content.sub(settings_require, "#{settings_require}#{requires}"))
233
259
  end
234
260
 
235
261
  # .env/.env.test/.env.example are the other deliberate exception to
@@ -252,6 +278,17 @@ module Monk
252
278
  example << "AUTH_SECRET=change-me"
253
279
  end
254
280
 
281
+ if @mail
282
+ # MAIL_URL is left out of .env on purpose: unset in development
283
+ # means log://, the message printed to the console. Tests boot
284
+ # outside development, where an unset one raises, hence log:// there.
285
+ dev << %(MAIL_FROM="#{app_name} <no-reply@localhost>")
286
+ test << "MAIL_URL=log://"
287
+ test << %(MAIL_FROM="#{app_name} <no-reply@localhost>")
288
+ example << "MAIL_URL=smtp://user:password@smtp.example.com:587"
289
+ example << %(MAIL_FROM="#{app_name} <no-reply@example.com>")
290
+ end
291
+
255
292
  # REDIS_URL is deliberately absent from .env.test -- only a test that
256
293
  # actually exercises RedisFanout needs it, unlike DB_NAME/AUTH_SECRET
257
294
  # which every test touching persistence/auth needs.
@@ -393,7 +430,7 @@ module Monk
393
430
  bin/server # HTTP app on :9292 -> http://localhost:9292/hello
394
431
  bin/websocket_server # WS chat process on :9293, in another terminal
395
432
  ```
396
- #{redis_only_note}
433
+ #{redis_only_note}#{mail_setup_note}
397
434
  ## Test environment
398
435
 
399
436
  `monk new` scaffolds no test framework at all -- this is the minimum to
@@ -415,9 +452,9 @@ module Monk
415
452
  $LOAD_PATH.unshift(File.expand_path("..", __dir__))
416
453
 
417
454
  ENV["MONK_ENV"] ||= "test"
418
-
455
+ #{base_test_dotenv_lines}
419
456
  require "minitest/autorun"
420
- require_relative "../config/settings"
457
+ require_relative "../config/settings"#{%(\nrequire_relative "../config/mail") if @mail}
421
458
  ```
422
459
 
423
460
  **Rakefile**:
@@ -553,7 +590,7 @@ module Monk
553
590
  bin/server # HTTP app on :9292
554
591
  bin/websocket_server # WS chat process on :9293, in another terminal
555
592
  ```
556
- #{auth_or_redis_confirmation_note}
593
+ #{auth_or_redis_confirmation_note}#{mail_setup_note}
557
594
  ## Test environment
558
595
 
559
596
  ### 1. Create the test database
@@ -600,7 +637,7 @@ module Monk
600
637
 
601
638
  require "minitest/autorun"
602
639
  require_relative "../config/settings"
603
- require_relative "../config/#{@auth ? "auth" : "persistence"}"
640
+ require_relative "../config/#{@auth ? "auth" : "persistence"}"#{%(\nrequire_relative "../config/mail") if @mail}
604
641
  ```
605
642
 
606
643
  **Rakefile**:
@@ -657,6 +694,43 @@ module Monk
657
694
  "#{visible} visible to it -- that confirms everything's actually wired up.\n"
658
695
  end
659
696
 
697
+ # Interpolated into a squiggly heredoc, so no leading indentation of
698
+ # its own (interpolated text isn't dedented).
699
+ def mail_setup_note
700
+ return "" unless @mail
701
+
702
+ <<~MARKDOWN
703
+
704
+ ### Email
705
+
706
+ `config/mail.rb` configures `Monk::Mail`; send with
707
+ `Monk::Mail.deliver(to:, subject:, text:, html:)`.#{auth_mail_sentence} In
708
+ development `MAIL_URL` is unset, so messages are printed to the
709
+ console instead of sent. Outside
710
+ development set `MAIL_URL` (e.g. `smtp://user:pass@smtp.provider.com:587`)
711
+ and `MAIL_FROM` to a sender on a domain you've verified with your
712
+ provider -- an unset `MAIL_URL` fails the boot there. Provider
713
+ examples: `docs/guides/mail.md` in the monk repo.
714
+ MARKDOWN
715
+ end
716
+
717
+ # The no-Postgres test helper normally has nothing to load from
718
+ # .env.test -- but with --mail it has MAIL_URL=log://, which tests need:
719
+ # they boot outside development, where an unset MAIL_URL raises.
720
+ # Interpolated into a squiggly heredoc, so no indentation of its own.
721
+ def base_test_dotenv_lines
722
+ return "" unless @mail
723
+
724
+ %(\nrequire "dotenv"\nDotenv.load(File.expand_path(".env.test", __dir__ + "/.."))\n)
725
+ end
726
+
727
+ def auth_mail_sentence
728
+ return "" unless @auth
729
+
730
+ " `config/auth.rb`'s `AppMailer::DELIVER` sends each login link through it " \
731
+ "(HTML part: `views/mail/magic_link.erb`)."
732
+ end
733
+
660
734
  def sample_test_body
661
735
  if @auth
662
736
  <<~RUBY.chomp.gsub(/^/, " ")
@@ -7,14 +7,24 @@ require_relative "persistence"
7
7
  # "#{Monk::Settings[:public_url]}/auth/callback/#{token}"), see its comment
8
8
  # there and auth.md's "Sending the magic link".
9
9
 
10
- # Sends the magic link once you have real delivery (email, SMS, ...) --
11
- # a module constant, not a lambda inline in Auth.configure below, since
12
- # self at this file's top level isn't Ractor-shareable (same constraint as
13
- # Monk::Live.authorize blocks). Until this is wired up for real,
14
- # Monk::Auth.deliver_link falls back to Monk::Auth.log_dev_link in
15
- # development and raises everywhere else -- see docs/guides/auth.md.
10
+ # Sends the magic link by email, through Monk::Mail (config/mail.rb, which
11
+ # config.ru requires) -- a module constant, not a lambda inline in
12
+ # Auth.configure below, since self at this file's top level isn't
13
+ # Ractor-shareable (same constraint as Monk::Live.authorize blocks). The
14
+ # HTML part is views/mail/magic_link.erb. Swap the body for SMS or any
15
+ # other channel; see docs/guides/auth.md.
16
16
  module AppMailer
17
- # DELIVER = ->(email:, link:, token:) { YourMailer.magic_link(email, link) }
17
+ DELIVER = lambda do |email:, link:, token:|
18
+ # Development only (a no-op elsewhere): the link on the console, plus a
19
+ # QR code for a phone if rqrcode is in the Gemfile.
20
+ Monk::Auth.log_dev_link(link, subject: email)
21
+ Monk::Mail.deliver(
22
+ to: email,
23
+ subject: "Your login link",
24
+ text: "Here's your login link:\n\n#{link}\n\nIf you didn't ask for it, you can ignore this email.",
25
+ html: Monk::Mail.render("mail/magic_link", link: link),
26
+ )
27
+ end
18
28
  end
19
29
 
20
30
  Monk::Auth.configure(
@@ -24,5 +34,5 @@ Monk::Auth.configure(
24
34
  session_ttl: 1_209_600, # seconds a session stays valid (14 days)
25
35
  redirect_allowlist: [], # paths request_login(redirect_to:) is allowed to target
26
36
  secure: !Monk.env.development?, # Secure cookie flag; off in dev so plain-http testing works (Safari drops it)
27
- # deliver: AppMailer::DELIVER,
37
+ deliver: AppMailer::DELIVER,
28
38
  )
@@ -0,0 +1,3 @@
1
+ <p>Here's your login link:</p>
2
+ <p><a href="<%= locals[:link] %>">Log in</a></p>
3
+ <p>If you didn't ask for it, you can ignore this email.</p>
@@ -0,0 +1 @@
1
+ gem "net-smtp" # Monk::Mail's smtp:// transport (config/mail.rb)
@@ -0,0 +1,19 @@
1
+ require "monk"
2
+ require "monk/mail"
3
+
4
+ # Sends email with Monk::Mail.deliver(to:, subject:, text:, html:) from
5
+ # any route -- and, with --auth, the magic links config/auth.rb's AppMailer
6
+ # delivers. See docs/guides/mail.md. Required from config.ru (not from
7
+ # config/auth.rb: bin/websocket_server loads that too, and never sends mail).
8
+ Monk::Settings.configure do
9
+ # Where mail goes. Unset in development means log:// (printed to the
10
+ # console, nothing sent); anywhere else an unset one fails the boot.
11
+ # e.g. smtp://user:pass@smtp.provider.com:587 or smtp://localhost:25,
12
+ # provider examples in docs/guides/mail.md.
13
+ optional :mail_url, default: ""
14
+ # The sender of every message, e.g. "My App <no-reply@myapp.com>" --
15
+ # a domain you've verified with your mail provider (SPF, DKIM).
16
+ optional :mail_from, default: "App <no-reply@localhost>"
17
+ end
18
+
19
+ Monk::Mail.configure(url: Monk::Settings[:mail_url], from: Monk::Settings[:mail_from])
data/lib/monk/version.rb CHANGED
@@ -5,5 +5,5 @@ module Monk
5
5
  # class of bug .freeze! guards against for routes/error handlers/models,
6
6
  # just on a top-level constant that every worker Ractor reads on boot
7
7
  # (found live under kino: GET /hello 500'd until this was frozen).
8
- VERSION = "0.16.0".freeze
8
+ VERSION = "0.17.0".freeze
9
9
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: monkrb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.16.0
4
+ version: 0.17.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matteo Folin
@@ -79,6 +79,20 @@ dependencies:
79
79
  - - "~>"
80
80
  - !ruby/object:Gem::Version
81
81
  version: '3.2'
82
+ - !ruby/object:Gem::Dependency
83
+ name: net-smtp
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '0.5'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '0.5'
82
96
  - !ruby/object:Gem::Dependency
83
97
  name: minitest
84
98
  requirement: !ruby/object:Gem::Requirement
@@ -175,6 +189,13 @@ files:
175
189
  - lib/monk/live/renderer.rb
176
190
  - lib/monk/live/session.rb
177
191
  - lib/monk/log.rb
192
+ - lib/monk/mail.rb
193
+ - lib/monk/mail/address.rb
194
+ - lib/monk/mail/errors.rb
195
+ - lib/monk/mail/message.rb
196
+ - lib/monk/mail/mime.rb
197
+ - lib/monk/mail/transports.rb
198
+ - lib/monk/mail/url.rb
178
199
  - lib/monk/persistence.rb
179
200
  - lib/monk/persistence/errors.rb
180
201
  - lib/monk/persistence/model.rb
@@ -188,6 +209,7 @@ files:
188
209
  - lib/monk/templates/auth/config/auth.rb
189
210
  - lib/monk/templates/auth/db/migrate/00000000000001_create_auth_tables.down.sql
190
211
  - lib/monk/templates/auth/db/migrate/00000000000001_create_auth_tables.up.sql
212
+ - lib/monk/templates/auth/views/mail/magic_link.erb
191
213
  - lib/monk/templates/base/.dockerignore
192
214
  - lib/monk/templates/base/.gitignore
193
215
  - lib/monk/templates/base/.ruby-version
@@ -207,6 +229,8 @@ files:
207
229
  - lib/monk/templates/live/config/live_pg.rb
208
230
  - lib/monk/templates/live/views/index.erb
209
231
  - lib/monk/templates/live/views/live/_hits.erb
232
+ - lib/monk/templates/mail/Gemfile.extra
233
+ - lib/monk/templates/mail/config/mail.rb
210
234
  - lib/monk/templates/postgres/Dockerfile
211
235
  - lib/monk/templates/postgres/Gemfile.extra
212
236
  - lib/monk/templates/postgres/bin/console