monkrb 0.15.1 → 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 +61 -0
- data/README.md +6 -5
- data/exe/monk +40 -11
- data/lib/monk/auth.rb +3 -3
- data/lib/monk/errors.rb +3 -0
- 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 +178 -22
- 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/live/config/live_pg.rb +60 -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
- data/lib/monk/websocket/errors.rb +7 -0
- data/lib/monk/websocket/pg_fanout.rb +174 -0
- metadata +27 -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,67 @@ 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
|
+
|
|
42
|
+
## 0.16.0 - 2026-09-24
|
|
43
|
+
|
|
44
|
+
### Added
|
|
45
|
+
|
|
46
|
+
- `Monk::WebSocket::PgFanout`: a Postgres `LISTEN`/`NOTIFY`-backed
|
|
47
|
+
alternative to `RedisFanout`, implementing the identical
|
|
48
|
+
`#register`/`#unregister`/`#count`/`#broadcast` interface, for an app
|
|
49
|
+
that already runs Postgres and doesn't need Redis as a second
|
|
50
|
+
dependency. Caps a single broadcast at just under 8000 bytes
|
|
51
|
+
(Postgres's own `NOTIFY` limit) and, unlike `RedisFanout`, has no
|
|
52
|
+
horizontal-scaling story for many WS processes or a hot topic.
|
|
53
|
+
`monk new --live --postgres` (without `--redis`) wires it automatically,
|
|
54
|
+
reusing the app's existing `DB_*` settings — no new configuration, no
|
|
55
|
+
Redis to run. See `docs/design/live-pg-fanout.md` and
|
|
56
|
+
`docs/history/plan-live-pg-fanout.md`.
|
|
57
|
+
|
|
58
|
+
### Changed
|
|
59
|
+
|
|
60
|
+
- **`monk new --live` no longer silently defaults to Redis.** It now
|
|
61
|
+
requires `--redis` or `--postgres` explicitly, raising
|
|
62
|
+
`Monk::AmbiguousLiveTransportError` with neither — there's no way to
|
|
63
|
+
tell which cross-process transport an app actually has available, so
|
|
64
|
+
guessing would be as likely to hand back a template that can't connect
|
|
65
|
+
as one that can. A script that ran `monk new my_app --live` needs
|
|
66
|
+
`--redis` added to keep the previous behavior.
|
|
67
|
+
|
|
7
68
|
## 0.15.1 - 2026-09-24
|
|
8
69
|
|
|
9
70
|
### Fixed
|
data/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
A light Ruby web framework designed to be fully `Ractor`-safe: every app it produces is a valid Rack 3 app that is also `Ractor.shareable?`, so it can be served in parallel across Ractor worker pools without silently losing that safety property. Named after Thelonious Sphere Monk, great and unique Jazz piano player and composer.
|
|
4
4
|
|
|
5
|
-
**Built in** (loaded by `require "monk"`): routing, context and error handling, boot-time Ractor-shareability checks, `Monk::StateRactor` for shared state, settings, ERB views, static assets and logging. **Opt-in** (each needs its own `require`): Postgres persistence and migrations, passwordless auth and sessions, a WebSocket server with Redis fan-out, and `Monk::Live` server-pushed HTML updates. Details for each are in the [Features](#features) table below.
|
|
5
|
+
**Built in** (loaded by `require "monk"`): routing, context and error handling, boot-time Ractor-shareability checks, `Monk::StateRactor` for shared state, settings, ERB views, static assets and logging. **Opt-in** (each needs its own `require`): Postgres persistence and migrations, passwordless auth and sessions, a WebSocket server with Redis or Postgres fan-out, and `Monk::Live` server-pushed HTML updates. Details for each are in the [Features](#features) table below.
|
|
6
6
|
|
|
7
7
|
Monk is Kino-agnostic — it's built on stdlib `Ractor` primitives only, with no runtime dependency on any particular server. [Kino](https://github.com/yaroslav/kino) is the reference/development server (see `bin/server`), but any Ractor-aware Rack server, or a conventional one, can run a Monk app.
|
|
8
8
|
|
|
@@ -60,8 +60,9 @@ 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
|
-
|
|
|
64
|
-
|
|
|
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) |
|
|
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) |
|
|
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) |
|
|
66
67
|
|
|
67
68
|
## More documentation
|
|
@@ -70,7 +71,7 @@ Everything beyond the core is opt-in (`require "monk"` alone loads none of it).
|
|
|
70
71
|
- [`docs/guides/deploying.md`](docs/guides/deploying.md): worked deployment examples.
|
|
71
72
|
- [`docs/design/ractor.md`](docs/design/ractor.md): how Monk uses Ruby's `Ractor`.
|
|
72
73
|
- [`docs/adr/`](docs/adr) and [`CONTEXT.md`](CONTEXT.md): architectural decisions and domain vocabulary.
|
|
73
|
-
- Design docs and phase-by-phase plans behind each feature: [`docs/design/views.md`](docs/design/views.md), [`docs/design/auth-sessions.md`](docs/design/auth-sessions.md), [`docs/design/websocket.md`](docs/design/websocket.md), [`docs/design/persistence-ractor-connections.md`](docs/design/persistence-ractor-connections.md), and the phase-by-phase plans and archived notes in [`docs/history/`](docs/history).
|
|
74
|
+
- Design docs and phase-by-phase plans behind each feature: [`docs/design/views.md`](docs/design/views.md), [`docs/design/auth-sessions.md`](docs/design/auth-sessions.md), [`docs/design/websocket.md`](docs/design/websocket.md), [`docs/design/persistence-ractor-connections.md`](docs/design/persistence-ractor-connections.md), [`docs/design/live-pg-fanout.md`](docs/design/live-pg-fanout.md), and the phase-by-phase plans and archived notes in [`docs/history/`](docs/history).
|
|
74
75
|
- [`CHANGELOG.md`](CHANGELOG.md).
|
|
75
76
|
|
|
76
77
|
## Working on this repo
|
|
@@ -85,6 +86,6 @@ More (server modes, Docker) in [`docs/development.md`](docs/development.md).
|
|
|
85
86
|
|
|
86
87
|
## Status
|
|
87
88
|
|
|
88
|
-
Monk is **pre-1.0** (see `lib/monk/version.rb` and the [changelog](CHANGELOG.md)): the API may still change between minor versions. The core is described in [`docs/history/core-plan.md`](docs/history/core-plan.md). There's no open roadmap issue at the moment; new work is proposed and tracked as it comes up. Done so far, each with its own design doc and plan: persistence, migrations, HTML templating and static assets, auth and sessions, WebSocket with Redis fan-out, log levels, live updates (`Monk::Live`, as of 2026-09-19), deployment support (Dockerfile scaffolding, `docs/guides/deploying.md`),
|
|
89
|
+
Monk is **pre-1.0** (see `lib/monk/version.rb` and the [changelog](CHANGELOG.md)): the API may still change between minor versions. The core is described in [`docs/history/core-plan.md`](docs/history/core-plan.md). There's no open roadmap issue at the moment; new work is proposed and tracked as it comes up. Done so far, each with its own design doc and plan: persistence, migrations, HTML templating and static assets, auth and sessions, WebSocket with Redis fan-out, log levels, live updates (`Monk::Live`, as of 2026-09-19), deployment support (Dockerfile scaffolding, `docs/guides/deploying.md`), the RubyGems release as `monkrb` (both 2026-09-22), and `Monk::WebSocket::PgFanout`/`monk new --live --postgres` — the same cross-process fan-out over Postgres `LISTEN`/`NOTIFY` instead of Redis, for an app that doesn't want a second piece of infrastructure (`docs/design/live-pg-fanout.md`, 2026-09-24).
|
|
89
90
|
|
|
90
91
|
[Made with Love ❤️, Ruby 💎 and AI 🤖](docs/ai_usage_disclaimer.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
|
|
@@ -53,13 +64,21 @@ HELP = <<~TEXT.freeze
|
|
|
53
64
|
|
|
54
65
|
--live Also scaffold Monk::Live: a demo counter whose open tabs
|
|
55
66
|
update by themselves, pushed as HTML over a WebSocket.
|
|
56
|
-
|
|
57
|
-
are separate processes,
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
67
|
+
Requires --redis or --postgres: bin/server and
|
|
68
|
+
bin/websocket_server are always separate processes, so
|
|
69
|
+
one of the two has to carry a publish between them --
|
|
70
|
+
with --redis, the usual Redis wiring; with --postgres
|
|
71
|
+
(and not --redis), Monk::WebSocket::PgFanout over
|
|
72
|
+
LISTEN/NOTIFY instead, reusing --postgres's own DB_*
|
|
73
|
+
settings, so there's no Redis to run. Passing both
|
|
74
|
+
picks --redis; passing neither is an error, since
|
|
75
|
+
there's no way to tell whether Postgres is available.
|
|
76
|
+
Adds
|
|
77
|
+
config/live.rb, views/live/_hits.erb and the browser
|
|
78
|
+
runtime under public/js/monk_live/, replaces config.ru,
|
|
79
|
+
views/index.erb and bin/websocket_server with live
|
|
80
|
+
versions, and adds the runtime's <meta>/<script> tags
|
|
81
|
+
to the layout.
|
|
63
82
|
|
|
64
83
|
Commands:
|
|
65
84
|
new APP_NAME Create a new project (see Options above)
|
|
@@ -101,16 +120,26 @@ when "new"
|
|
|
101
120
|
usage_error! if app_name.nil?
|
|
102
121
|
|
|
103
122
|
auth = rest.include?("--auth")
|
|
123
|
+
mail = rest.include?("--mail") || auth
|
|
104
124
|
redis = rest.include?("--redis")
|
|
105
125
|
live = rest.include?("--live")
|
|
106
|
-
Monk::Scaffold.new(
|
|
126
|
+
Monk::Scaffold.new(
|
|
127
|
+
app_name, postgres: rest.include?("--postgres"), auth: auth, mail: mail, redis: redis, live: live,
|
|
128
|
+
).write!
|
|
107
129
|
|
|
108
130
|
puts "Created #{app_name}. Next steps:"
|
|
109
131
|
puts " cd #{app_name}"
|
|
110
132
|
puts " bundle install"
|
|
111
133
|
puts " see SETUP.md for the full dev-then-test walkthrough"
|
|
112
134
|
puts " change the placeholder AUTH_SECRET in .env/.env.test before relying on it" if auth
|
|
113
|
-
puts "
|
|
135
|
+
puts " set MAIL_URL and MAIL_FROM outside development (see config/mail.rb)" if mail
|
|
136
|
+
if live
|
|
137
|
+
if redis
|
|
138
|
+
puts " Monk::Live needs Redis running, bin/server and bin/websocket_server (see SETUP.md)"
|
|
139
|
+
else
|
|
140
|
+
puts " Monk::Live needs bin/server and bin/websocket_server running, sharing Postgres (see SETUP.md)"
|
|
141
|
+
end
|
|
142
|
+
end
|
|
114
143
|
else
|
|
115
144
|
usage_error!
|
|
116
145
|
end
|
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.
|
data/lib/monk/errors.rb
CHANGED
|
@@ -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
|