mailmate 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 79154ae28a8d2c2aaf816e6719da7ee549f59db9a9425c19fd14971fa17c2752
4
+ data.tar.gz: 3ebd8963699820c5a10a777ea1d0013108301f268bd4a59939e5a9cf54649747
5
+ SHA512:
6
+ metadata.gz: 0f709dfccd86d6abb7e82ec1da45fc7f84745a6967f7e9fdaa90b1b90ae015671271102016f03aea80262ce8ff6ca109d7d8b5365d012c95ab4cc57c102e43ed
7
+ data.tar.gz: 1da1debaba2cb1260b8b033efb742ec45bca0b37c5b041f87492367bbf0f9efda29cf117723cd1bf027d7b84ccfd4947f8dc84d545759b36ad8f346b0eb4121c
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Brian Murphy-Dye
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,212 @@
1
+ # mailmate
2
+
3
+ Ruby toolkit for [MailMate](https://freron.com) on macOS — a smart-mailbox filter engine, on-disk index readers, and CLI tools (`mmsearch`, `mmmessage`, `mm-modify`, `mm-send`, `mmdiscover`) for searching, reading, modifying, and sending mail.
4
+
5
+ **Requires macOS with MailMate installed.** The library code (filter parser, evaluator) works anywhere, but the integration with MailMate itself — AppleScript, on-disk index reads, the `emate` binary — is macOS-only by way of MailMate being macOS-only.
6
+
7
+ ## Example usage
8
+
9
+ ### `mmsearch` — find messages
10
+
11
+ ```bash
12
+ # Default: today's mail, all mailboxes
13
+ mmsearch
14
+
15
+ # From "Medium" in the last 7 days
16
+ mmsearch 'f medium d 7d'
17
+
18
+ # Subject contains "rent due", not the word "draft"
19
+ mmsearch 's "rent due" !draft'
20
+
21
+ # Received in May 2026
22
+ mmsearch 'd 2026-05'
23
+
24
+ # Custom columns + cap results + raw CSV (no padding)
25
+ mmsearch 'f acme' 'id flags subject from' --limit 20 --no-align
26
+ ```
27
+
28
+ ### `mmmessage` — read one message
29
+
30
+ ```bash
31
+ # By local eml-id (the integer Msg ID column in MailMate)
32
+ mmmessage 183715
33
+
34
+ # By portable Message-ID (quote it — the angle brackets are shell metacharacters)
35
+ mmmessage '<CA+abc123@mail.example.com>'
36
+
37
+ # Raw .eml bytes (e.g. to pipe into `mail` parsers)
38
+ mmmessage 183715 --raw
39
+
40
+ # Body only, no headers block
41
+ mmmessage 183715 --text-only
42
+ ```
43
+
44
+ ### `mm-modify` — change message state
45
+
46
+ ```bash
47
+ # Mark a message read, flag it, and archive it — one open/wait cycle
48
+ mm-modify 183715 read flag archive
49
+
50
+ # Add a tag
51
+ mm-modify 183715 tag urgent
52
+
53
+ # Dry-run first
54
+ mm-modify 183715 archive --dry-run
55
+
56
+ # Verify the new flags after acting
57
+ mm-modify 183715 read --verify
58
+ ```
59
+
60
+ ### `mm-send` — send mail
61
+
62
+ `mm-send` is a thin wrapper around MailMate's bundled `emate mailto`, with `--markup markdown` enforced. The body is read from stdin.
63
+
64
+ ```bash
65
+ # One-liner
66
+ echo "Quick **markdown** body." | mm-send -t friend@example.com -s "Hello"
67
+
68
+ # Heredoc with cc + send-now
69
+ mm-send -t friend@example.com -c cc@example.com -s "Status update" --send-now <<'EOF'
70
+ ## Update
71
+
72
+ - shipped the thing
73
+ - on to the next
74
+ EOF
75
+
76
+ # Attach files (positional args after options)
77
+ mm-send -t friend@example.com -s "Photos" /path/to/photo1.jpg /path/to/photo2.jpg <<<"See attached."
78
+ ```
79
+
80
+ ### Why the names
81
+
82
+ The `mm` prefix is for tab completion: typing `mm<tab>` in a shell lists every command in the toolkit. The dash matters:
83
+
84
+ - **`mm<name>`** (no dash) — **read** operations. `mmsearch`, `mmmessage`, `mmdiscover` only observe MailMate's on-disk state.
85
+ - **`mm-<name>`** (with dash) — **write** operations. `mm-modify`, `mm-send` change state (or send mail). Typing `mm-<tab>` filters to just the write commands so you can see at a glance what mutates.
86
+
87
+ ## Limitations
88
+
89
+ A few rough edges to be aware of:
90
+
91
+ 1. **Search is slow against this wrapper, even though MailMate itself is fast.** MailMate has a fantastic search engine — its native quicksearch is near-instant even against large stores — but `mmsearch` doesn't yet route through it. The current implementation uses index prefilters plus direct `.eml` walks, which works but is orders of magnitude slower than MailMate's own UI search, and **painfully** slow once a body-text term (`b <term>`, a bare term that falls through to body matching, or a `--no-headers-only` query) disables the prefilter. Prefer narrowing with `f`/`t`/`s`/`d` first, and use `--headers-only` when you don't need the body matched. Finding a way to drive MailMate's native engine from the outside is open work.
92
+
93
+ 2. **Bulk `mm-modify` takes over the whole computer, not just MailMate.** Each invocation opens a message-viewer window via the `mid:` URL, runs AppleScript key-binding selectors against it, then closes the window. Two things follow from that:
94
+ - **Focus is stolen.** When the `mid:` URL fires, macOS brings MailMate forward and the spawned message-viewer window takes keyboard focus. Anything you were typing into another app goes to MailMate instead.
95
+ - **The close at the end can close the wrong window.** `mm-modify` ends by sending the standard "close window" keystroke. If focus has drifted (or the next app's window has come forward in the meantime), that keystroke lands on **your** window — your editor, your browser tab — not MailMate's viewer.
96
+
97
+ For one-off changes this is just annoying; for a loop of hundreds of messages it makes the machine unusable while it runs. Batch multiple actions into one `mm-modify` invocation when you can — they share a single open/close cycle. The `--keep-window` flag avoids the close-keystroke entirely if you don't mind cleaning up viewers manually.
98
+
99
+ 3. **`eml-id` is machine-local; prefer `Message-ID:`.** The integer eml-id (also shown as MailMate's "Msg ID" column) is just the filename of the `.eml` on disk and differs on every install — copy/pasting an eml-id from your desktop to your laptop will refer to a different message (or none at all). For anything you want to keep, store the RFC `Message-ID:` header (which `mmmessage` prints) and pass that to the CLIs. The `mid:%3C<message-id>%3E` URL scheme works portably for the same reason.
100
+
101
+ 4. **MailMate must be running.** Anything that goes through `mm-modify` requires MailMate open and unblocked by modal dialogs. `mm-send` likewise needs MailMate running — `emate mailto` opens a draft window in the running MailMate process, so without MailMate up there's nowhere for the draft to land (this is true with or without `--send-now`). Headless / unattended use isn't supported.
102
+
103
+ 5. **Single-account `mm-send` defaults.** `mm-send` passes flags straight through to `emate mailto`. If you have multiple identities configured in MailMate and don't pass `-f`, MailMate picks the default identity — there's no opinionated multi-account routing in the wrapper.
104
+
105
+ ## Status
106
+
107
+ Pre-1.0 (0.x). Breaking changes allowed without version bumps. See [`docs/roadmap/Mailmate gem.md`](../claude/people/docs/roadmap/Mailmate%20gem.md) in the sibling `people` repo for the design history and remaining work.
108
+
109
+ ## Install
110
+
111
+ For development (no `gem install` needed):
112
+
113
+ ```bash
114
+ git clone <this repo> ~/code/claude/mailmate
115
+ echo 'export PATH="$HOME/code/claude/mailmate/exe:$PATH"' >> ~/.zshrc
116
+ source ~/.zshrc
117
+ ```
118
+
119
+ Then bootstrap your config:
120
+
121
+ ```bash
122
+ mmdiscover
123
+ ```
124
+
125
+ `mmdiscover` reads MailMate's `Sources.plist` and `Identities.plist`, shows you the accounts and addresses it found, and offers to write `~/.config/mailmate/config.yml` from them. It also writes `~/.config/mailmate/bundle_loader.rb` for MailMate bundles.
126
+
127
+ ## Commands
128
+
129
+ | Command | What it does |
130
+ |---|---|
131
+ | `mmsearch` | List messages matching a quicksearch expression. Output is aligned CSV. |
132
+ | `mmmessage` | Print one message by `.eml` id (decoded headers + plain-text body). |
133
+ | `mm-modify` | Mark read/flag/tag/archive/move a message via AppleScript. |
134
+ | `mm-send` | Send mail through `emate` with a markdown body on stdin. |
135
+ | `mmdiscover` | First-run bootstrap; (re-)writes the user config from MailMate's plists. |
136
+
137
+ Each command takes `--help` for usage. Tab-completion: `mm<tab>` lists every command; `mms<tab>` → `mmsearch`; `mmm<tab>` → `mmmessage`; `mm-<tab>` lists the write-side commands.
138
+
139
+ ## eml-id vs Message-ID
140
+
141
+ The CLI tools take an `eml-id` — the integer filename of MailMate's `.eml` storage (the same value as the **Msg ID** column in MailMate's UI, internally MailMate's `#body-part-id`). It's a counter MailMate maintains locally; **eml-ids are NOT portable across machines.** The same RFC `Message-ID:` will have a different eml-id on every install. If you need a cross-machine reference, use the message's `Message-ID:` header (which `mmmessage <id>` prints). The `mid:%3C<message-id>%3E` URL scheme works portably for the same reason.
142
+
143
+ ## Library
144
+
145
+ ```ruby
146
+ require "mailmate"
147
+
148
+ # Parse and evaluate a MailMate smart-mailbox filter
149
+ ast = Mailmate.compile_filter("from.name = 'Medium' and #date-received > '1 days ago'")
150
+ # ... feed `ast` to Mailmate::Evaluator ...
151
+
152
+ # Read the binary `#flags` index
153
+ reader = Mailmate::IndexReader.for("#flags")
154
+ reader.flags_for(180644) # → ["\\Seen", "$Forwarded"]
155
+
156
+ # Configuration
157
+ Mailmate.config.app_support_dir # → expanded path
158
+ Mailmate::Identity.mine?("brian@example.com") # → true if in identities
159
+ ```
160
+
161
+ ## Using from a MailMate bundle
162
+
163
+ `mmdiscover` writes `~/.config/mailmate/bundle_loader.rb` — a one-line bootstrap that lets MailMate bundle handlers find the gem. Every handler then does:
164
+
165
+ ```ruby
166
+ #!/usr/bin/env ruby
167
+ load File.expand_path("~/.config/mailmate/bundle_loader.rb")
168
+ require "mailmate"
169
+
170
+ # ... use Mailmate::IndexReader, Mailmate::HeaderReader, Mailmate::Identity, etc.
171
+ ```
172
+
173
+ The bootstrap file is the only place that knows the gem's path on disk, so individual bundles stay portable across machines — copy a `.mmBundle/` to another Mac, run `mmdiscover` there, and the bundle works.
174
+
175
+ ### Sample bundle
176
+
177
+ The gem ships a working sample at `~/Library/Application Support/MailMate/Bundles/Mailmate.mmBundle/`:
178
+
179
+ - **`Commands/Inbox Note.mmCommand`** — declares input (canonical body), env vars (from / subject / date / message-id), and output type (actions JSON).
180
+ - **`Support/bin/inbox_note.rb`** — the handler. Reads body from stdin, headers from env, uses `Mailmate::Identity.mine?` to decide inbound/outbound, writes a markdown note to `~/code/claude/people/projects/email/inbox/<YYYY>/<MM>/`, and returns `moveMessage` (archive) + `notify` actions.
181
+
182
+ To enable: restart MailMate (or use "Reload Bundles" in the Command menu). The "→ Inbox Note" entry will appear in `Command → Mailmate gem bundle`. Override the output directory by setting `MAILMATE_INBOX_DIR` in the `.mmCommand`'s `environment` block.
183
+
184
+ See `~/code/claude/people/projects/email/mailmate-bundles.md` for the bundle plist mechanics in full.
185
+
186
+ ## Configuration
187
+
188
+ Loading order: built-in defaults → `~/.config/mailmate/config.yml` → environment variables (override YAML).
189
+
190
+ Available settings:
191
+
192
+ | Key (YAML) | Env var | Default |
193
+ |---|---|---|
194
+ | `app_support_dir` | `MAILMATE_APP_SUPPORT_DIR` | `~/Library/Application Support/MailMate` |
195
+ | `identities` (array) | `MAILMATE_IDENTITIES` (comma-separated) | `[]` |
196
+
197
+ A sample `config.yml.example` ships in the repo with placeholder values.
198
+
199
+ ## Tests
200
+
201
+ Two suites:
202
+
203
+ ```bash
204
+ rake test # hermetic — no MailMate required, runs anywhere
205
+ rake test:live # live — runs against your actual MailMate install
206
+ ```
207
+
208
+ `rake test:live` smoke-tests every smart mailbox in your `Mailboxes.plist`, decodes every `Database.noindex/Headers/*` index, and verifies one message round-trips through `EmlLookup` → `HeaderReader` → `MidUrl`. It's user-runnable so you can verify the gem works on your machine.
209
+
210
+ ## License
211
+
212
+ MIT. See `LICENSE.txt`.
@@ -0,0 +1,21 @@
1
+ # Mailmate gem configuration. Copy to ~/.config/mailmate/config.yml and edit,
2
+ # or run `mmdiscover` to have it generated for you from MailMate's own
3
+ # Sources.plist and Identities.plist.
4
+
5
+ # Optional. Override only if MailMate isn't installed at the default location.
6
+ # The loader expands ~ before use.
7
+ # app_support_dir: ~/Library/Application Support/MailMate
8
+
9
+ # Required for the "is this email mine?" check used by the search CLI's
10
+ # `direction` and `party` output fields. List every address you send mail
11
+ # from across all configured MailMate accounts (Gmail, iCloud, aliases, etc.).
12
+ identities:
13
+ - me@example.com
14
+ - me@example.org
15
+
16
+ # Optional. How to display dates and times in `mmsearch` / `mmmessage`.
17
+ # Unset (default): use the system's local time zone — handles DST automatically.
18
+ # Set to a fixed offset string (e.g. "-07:00" for MST, "-05:00" for EST) to
19
+ # pin a specific zone regardless of the system. IANA names (e.g.
20
+ # "America/Denver") are not supported; use the system zone for DST handling.
21
+ # display_timezone: "-07:00"
data/exe/mm-modify ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
+ require "mailmate"
6
+ require "mailmate/cli/modify"
7
+
8
+ exit Mailmate::CLI::Modify.run(ARGV)
data/exe/mm-send ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
+ require "mailmate"
6
+ require "mailmate/cli/send"
7
+
8
+ exit Mailmate::CLI::Send.run(ARGV)
data/exe/mmdiscover ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
+ require "mailmate"
6
+ require "mailmate/cli/discover"
7
+
8
+ exit Mailmate::CLI::Discover.run(ARGV)
data/exe/mmmessage ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
+ require "mailmate"
6
+ require "mailmate/cli/message"
7
+
8
+ exit Mailmate::CLI::Message.run(ARGV)
data/exe/mmsearch ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
+ require "mailmate"
6
+ require "mailmate/cli/search"
7
+
8
+ exit Mailmate::CLI::Search.run(ARGV)
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+
5
+ module Mailmate
6
+ # @api public
7
+ #
8
+ # Build and run AppleScript calls against MailMate. macOS-only.
9
+ #
10
+ # A class (not a module like the stateless utilities `HeaderReader`,
11
+ # `MidUrl`, `Identity`, etc.) because each driver carries per-invocation
12
+ # state — `dry_run`, `output`, `errput`. The rule throughout the gem:
13
+ # stateless surface → module with `extend self`; state-bearing → class.
14
+ class AppleScriptDriver
15
+ class Error < StandardError; end
16
+
17
+ attr_reader :dry_run
18
+
19
+ def initialize(dry_run: false, output: $stdout, errput: $stderr)
20
+ @dry_run = dry_run
21
+ @output = output
22
+ @errput = errput
23
+ end
24
+
25
+ # Drive a MailMate selector against the current selection.
26
+ # `selector` is the key-binding selector name (`"markAsRead:"`,
27
+ # `"setTag:"`, etc.). `args` are positional arguments passed alongside.
28
+ def perform(selector, *args)
29
+ Mailmate::PlatformError.check_darwin!(component: "AppleScriptDriver") unless dry_run
30
+ script = build_perform_script(selector, args)
31
+ run_apple_script(script)
32
+ end
33
+
34
+ # Open a URL in MailMate (used for `mid:` URLs to select a message).
35
+ def open_url(url)
36
+ Mailmate::PlatformError.check_darwin!(component: "AppleScriptDriver") unless dry_run
37
+ if dry_run
38
+ @output.puts "DRY: open -a MailMate #{url.inspect}"
39
+ return
40
+ end
41
+ success = system("open", "-a", "MailMate", url)
42
+ raise Error, "open command failed for #{url}" unless success
43
+ end
44
+
45
+ # Return the array of MailMate's current window IDs (integers). Empty
46
+ # array if MailMate isn't running or `osascript` errors.
47
+ def window_ids
48
+ return [] if dry_run
49
+ out = `osascript -e 'tell application "MailMate" to get id of every window' 2>&1`
50
+ return [] unless $?.success?
51
+ out.strip.split(",").map { |s| s.strip.to_i }
52
+ end
53
+
54
+ # Close every window in `ids`. No-op for IDs that no longer exist.
55
+ def close_windows(ids)
56
+ return if dry_run
57
+ Array(ids).each do |id|
58
+ `osascript -e 'tell application "MailMate" to close window id #{id}' >/dev/null 2>&1`
59
+ end
60
+ end
61
+
62
+ # Internal — build the `tell application "MailMate" to perform { ... }`
63
+ # script with proper quoting. AppleScript string literals require:
64
+ # \ → \\ (single backslash becomes \\, otherwise it interprets
65
+ # \b / \n / \t / etc. as control-character escapes)
66
+ # " → \" (terminates the string otherwise)
67
+ # Newlines and tabs in args are rejected — they have no defensible meaning
68
+ # inside an AppleScript single-line script and almost certainly indicate
69
+ # data the caller doesn't want injected.
70
+ def build_perform_script(selector, args)
71
+ escaped_selector = applescript_escape(selector.to_s, allow_controls: false)
72
+ if args.empty?
73
+ %(tell application "MailMate" to perform {"#{escaped_selector}"})
74
+ else
75
+ list = args.map { |a| %("#{applescript_escape(a.to_s)}") }.join(", ")
76
+ %(tell application "MailMate" to perform {"#{escaped_selector}", #{list}})
77
+ end
78
+ end
79
+
80
+ # Escape a Ruby string for inclusion inside an AppleScript double-quoted
81
+ # string literal. Order matters: backslash first, then quote. Newlines/
82
+ # tabs are rejected unless `allow_controls: true`.
83
+ def applescript_escape(s, allow_controls: false)
84
+ if !allow_controls && s.match?(/[\r\n\t]/)
85
+ raise Error, "AppleScript arg contains control character (\\r/\\n/\\t): #{s.inspect}"
86
+ end
87
+ s.gsub("\\", "\\\\\\\\").gsub('"', '\\"')
88
+ end
89
+
90
+ private
91
+
92
+ def run_apple_script(script)
93
+ if dry_run
94
+ @output.puts "DRY: osascript -e #{script.inspect}"
95
+ return
96
+ end
97
+ output = `osascript -e #{Shellwords.escape(script)} 2>&1`
98
+ status = $?.exitstatus
99
+ @errput.puts "[osascript] #{output.strip}" unless output.strip.empty?
100
+ raise Error, "osascript exited #{status}: #{script}" unless status.zero?
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ # AST nodes for MailMate filter expressions. All nodes implement #inspect for
4
+ # debug output and are evaluated by Mailmate::Evaluator.
5
+
6
+ module Mailmate
7
+ # @api private
8
+ module AST
9
+ AndNode = Struct.new(:children) { def inspect; "And(#{children.map(&:inspect).join(", ")})"; end }
10
+ OrNode = Struct.new(:children) { def inspect; "Or(#{children.map(&:inspect).join(", ")})"; end }
11
+ NotNode = Struct.new(:child) { def inspect; "Not(#{child.inspect})"; end }
12
+
13
+ # path: array of strings, e.g. ["from", "name"] or ["#any-address"]
14
+ # op: one of "=", "!=", "~", "!~", "<", "<=", ">", ">="
15
+ # flags: array of single-letter strings, e.g. ["c"], ["c", "a"], or []
16
+ # value: a *Node (LiteralStringNode, NumberNode, RelativeDateNode, AbsoluteDateNode, VarRefNode)
17
+ CompareNode = Struct.new(:path, :op, :flags, :value) do
18
+ def inspect; "Compare(#{path.join(".")} #{op}#{flags.empty? ? "" : "[#{flags.join}]"} #{value.inspect})"; end
19
+ end
20
+
21
+ ExistsNode = Struct.new(:path) { def inspect; "Exists(#{path.join(".")})"; end }
22
+
23
+ LiteralStringNode = Struct.new(:value) { def inspect; "Str(#{value.inspect})"; end }
24
+ NumberNode = Struct.new(:value) { def inspect; "Num(#{value})"; end }
25
+
26
+ # n: integer; unit: :day, :week, :month, :year
27
+ RelativeDateNode = Struct.new(:n, :unit) { def inspect; "Rel(#{n} #{unit}s ago)"; end }
28
+ AbsoluteDateNode = Struct.new(:time) { def inspect; "Abs(#{time.iso8601})"; end }
29
+
30
+ # Stage C placeholder: $SENT.from.address style references.
31
+ VarRefNode = Struct.new(:var, :path) { def inspect; "Var($#{var}.#{path.join(".")})"; end }
32
+ end
33
+ end