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 +4 -4
- data/CHANGELOG.md +35 -0
- data/README.md +1 -0
- data/exe/monk +18 -3
- data/lib/monk/auth.rb +3 -3
- data/lib/monk/mail/address.rb +41 -0
- data/lib/monk/mail/errors.rb +28 -0
- data/lib/monk/mail/message.rb +66 -0
- data/lib/monk/mail/mime.rb +124 -0
- data/lib/monk/mail/transports.rb +115 -0
- data/lib/monk/mail/url.rb +76 -0
- data/lib/monk/mail.rb +121 -0
- data/lib/monk/scaffold.rb +87 -13
- data/lib/monk/templates/auth/config/auth.rb +18 -8
- data/lib/monk/templates/auth/views/mail/magic_link.erb +3 -0
- data/lib/monk/templates/mail/Gemfile.extra +1 -0
- data/lib/monk/templates/mail/config/mail.rb +19 -0
- data/lib/monk/version.rb +1 -1
- metadata +25 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b983d19c7b6de8509f55f50c796d00d90c2d5d345cbe2a9e0f30e8f1349fe7be
|
|
4
|
+
data.tar.gz: 0fc9a16f56f69aafd1f3937268d337054399c7de34dd1a9a4986d3a6ae3e1bd5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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(
|
|
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
|
|
94
|
-
#
|
|
95
|
-
# it
|
|
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
|
-
|
|
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 --
|
|
154
|
-
# putting in an env file (a database, a REDIS_URL) -- the
|
|
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
|
-
|
|
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}
|
|
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
|
|
11
|
-
# a module constant, not a lambda inline in
|
|
12
|
-
# self at this file's top level isn't
|
|
13
|
-
# Monk::Live.authorize blocks).
|
|
14
|
-
#
|
|
15
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
37
|
+
deliver: AppMailer::DELIVER,
|
|
28
38
|
)
|
|
@@ -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.
|
|
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.
|
|
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
|