From e1c749555f8f273a02ab890ce9b66cfb7dc9fab4 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 6 Jul 2026 23:09:59 +0200 Subject: [PATCH] Bind Action Mailer (go-ruby-actionmailer) as a native module Add `require "action_mailer"` support backed by github.com/go-ruby-actionmailer/actionmailer, so mailer classes compose and deliver mail with no .rb file, mirroring the existing Mail / ActiveJob bindings. Surface (ActionMailer::Base subclass model): - class DSL: `default`, `delivery_method=`/`delivery_method`, `perform_deliveries=`, `raise_delivery_errors=`, `register_interceptor`, `register_observer`, the shared `deliveries` sink and the `renderer=`/ `enqueuer=` injection points; - each mailer action method (`def welcome`) becomes a class method via the method_added hook, returning an ActionMailer::MessageDelivery (`deliver_now` / `deliver` / `deliver_later` / `message`); - the mailer instance surface a `def welcome` body runs against: `mail(...)`, `attachments` (regular + `attachments.inline`) and `headers(hash)`. Four Ruby-dispatch seams, all run INLINE under the GVL: - Action: the mailer method body is sent to a freshly allocated mailer instance bound to the library *Mailer; a raise maps to an error the delivery proxy re-raises faithfully; - RenderBody: body rendering sends #render to the configured Ruby renderer (Action View in the consolidated binary; a stub in tests); a nil renderer or nil result skips the format; - DeliveryMethod: defaults to the :test method (append to ActionMailer::Base.deliveries); a #deliver object plugs in a custom transport; - EnqueueJob: deliver_later sends #enqueue to the configured Ruby enqueuer (Active Job in the consolidated binary; a stub in tests), else runs inline. 100% coverage of the new files (compose/alternative/related/mixed, attachments, interceptors/observers, deliver_now/later, delivery-error raise paths, all seams via injected stubs). gofmt + go vet clean; builds on all six 64-bit arches. Co-Authored-By: Claude Opus 4.8 --- go.mod | 1 + go.sum | 2 + internal/vm/actionmailer.go | 305 ++++++++++++++++++ internal/vm/actionmailer_bind.go | 518 +++++++++++++++++++++++++++++++ internal/vm/actionmailer_test.go | 494 +++++++++++++++++++++++++++++ internal/vm/builtins.go | 1 + internal/vm/object_model.go | 4 + internal/vm/require.go | 1 + internal/vm/vm.go | 9 + 9 files changed, 1335 insertions(+) create mode 100644 internal/vm/actionmailer.go create mode 100644 internal/vm/actionmailer_bind.go create mode 100644 internal/vm/actionmailer_test.go diff --git a/go.mod b/go.mod index cfca96e2..4d548244 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/go-ruby-abbrev/abbrev v0.0.0-20260629150957-97117892cd38 github.com/go-ruby-acme/acme v0.0.0-20260704112859-415fad2a4cbe github.com/go-ruby-actioncable/actioncable v0.0.0-20260706115409-19f2e354a783 + github.com/go-ruby-actionmailer/actionmailer v0.0.0-20260706115414-f116fa6c4917 github.com/go-ruby-activejob/activejob v0.0.0-20260706172137-830f5bbe94b4 github.com/go-ruby-activemodel/activemodel v0.0.0-20260706115433-f36d70b64b7a github.com/go-ruby-activerecord/activerecord v0.0.0-20260702222646-da57bd9e07f6 diff --git a/go.sum b/go.sum index fdcd8f98..262bf56d 100644 --- a/go.sum +++ b/go.sum @@ -192,6 +192,8 @@ github.com/go-ruby-acme/acme v0.0.0-20260704112859-415fad2a4cbe h1:oYFDl+kcAkTOX github.com/go-ruby-acme/acme v0.0.0-20260704112859-415fad2a4cbe/go.mod h1:Bh8Vr5Vy9UO7JqU5f6myUOWl0jjnnvtlGdIVTBbdLRw= github.com/go-ruby-actioncable/actioncable v0.0.0-20260706115409-19f2e354a783 h1:VAFOmKa1u2HKA2LFQhelusPVIAtgp+wAOkXl8IjURbQ= github.com/go-ruby-actioncable/actioncable v0.0.0-20260706115409-19f2e354a783/go.mod h1:5DS3icNZKImRwvDP7Kf9IHv+9d6EYtcvRpBALgh/s/Y= +github.com/go-ruby-actionmailer/actionmailer v0.0.0-20260706115414-f116fa6c4917 h1:f1xzBCccdUn49fRLh8XBn/d9OHpcrNa46w6aW0hGjyY= +github.com/go-ruby-actionmailer/actionmailer v0.0.0-20260706115414-f116fa6c4917/go.mod h1:CgixBW5KpbPEx+8SCZJQbeVa2WLNB7ObGRRCsjDEY+Y= github.com/go-ruby-activejob/activejob v0.0.0-20260706172137-830f5bbe94b4 h1:vRJAWboG75rQWpfTiNQ8XkFRJ1Am9RNKdRKSlTLQBiA= github.com/go-ruby-activejob/activejob v0.0.0-20260706172137-830f5bbe94b4/go.mod h1:HBZ9KJKoGTzo4h6/5+gaYyzMYWQtucT660mE68zHZGw= github.com/go-ruby-activemodel/activemodel v0.0.0-20260706115433-f36d70b64b7a h1:d8kCh4DxIJEQJhd/ZD6niTnxEzWgnZ5NlOmxfdBRDZQ= diff --git a/internal/vm/actionmailer.go b/internal/vm/actionmailer.go new file mode 100644 index 00000000..5dfd3a48 --- /dev/null +++ b/internal/vm/actionmailer.go @@ -0,0 +1,305 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// registerActionMailer installs the Action Mailer surface (require +// "action_mailer"), backed by the pure-Go engine of +// github.com/go-ruby-actionmailer/actionmailer: +// +// - ActionMailer::Base, the class every `class MyMailer < ActionMailer::Base` +// subclasses: the class-level DSL (default, delivery_method=, +// perform_deliveries=, raise_delivery_errors=, register_interceptor / +// register_observer), the shared deliveries Array, and the render/enqueue +// injection seams (renderer= / enqueuer=). Each mailer action method +// (`def welcome`) becomes callable as a class method via the method_added +// hook, returning an ActionMailer::MessageDelivery; +// - ActionMailer::MessageDelivery, the lazy proxy `MyMailer.welcome(user)` +// returns: deliver_now / deliver_later / message; +// - the mailer instance surface a `def welcome` body runs against: mail(...), +// attachments (attachments[name] = data / attachments.inline[name] = data) +// and headers(hash). +// +// The message composition, the multipart MIME assembly, the delivery-method +// registry and the interceptor/observer hooks live in the library; this file is +// the class and method wiring, and actionmailer_bind.go holds the four Ruby +// seams (Action, RenderBody, DeliveryMethod, EnqueueJob) and the Ruby <-> +// actionmailer value conversions. Every seam runs INLINE on the VM goroutine +// under the GVL, so a mailer action composes synchronously and (with the default +// :test delivery) deliver_now appends to ActionMailer::Base.deliveries. +func (vm *VM) registerActionMailer() { + mod := newClass("ActionMailer", nil) + mod.isModule = true + vm.consts["ActionMailer"] = mod + + vm.amDeliveries = object.NewArrayFromSlice(nil) + + base := newClass("ActionMailer::Base", vm.cObject) + mod.consts["Base"] = base + vm.consts["ActionMailer::Base"] = base + vm.cActionMailerBase = base + + del := newClass("ActionMailer::MessageDelivery", vm.cObject) + mod.consts["MessageDelivery"] = del + vm.consts["ActionMailer::MessageDelivery"] = del + vm.cActionMailerDelivery = del + vm.registerActionMailerDelivery(del) + + // An internal proxy class for the `attachments` accessor (mirroring + // Mail::AttachmentsList); not a documented constant, but carried on the value + // so classOf reports it. + att := newClass("ActionMailer::Base::Attachments", vm.cObject) + base.consts["Attachments"] = att + vm.cActionMailerAttachments = att + vm.registerActionMailerAttachments(att) + + vm.registerActionMailerDSL(base) + vm.registerActionMailerInstance(base) +} + +// registerActionMailerDSL installs the class-level Action Mailer DSL on +// ActionMailer::Base (inherited by every mailer subclass): the method_added hook +// that turns each mailer action method into a class method, the default / +// delivery / hook declarations, the shared deliveries accessor and the +// render/enqueue injection seams. +func (vm *VM) registerActionMailerDSL(base *RClass) { + sm := func(name string, fn NativeFn) { + base.smethods[name] = &Method{name: name, owner: base, native: fn} + } + + // method_added(:name) fires for every instance method a mailer subclass + // defines; each becomes a class method that runs the action and returns a + // MessageDelivery (the analogue of Rails resolving action_methods lazily). + sm("method_added", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + if cls, ok := self.(*RClass); ok && len(args) > 0 { + vm.amDefineAction(cls, amKeyStr(args[0])) + } + return object.NilV + }) + + // default(key: value, …) records the class's default headers/params, merged + // down the ancestor chain at delivery time. + sm("default", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + d := vm.amDefOf(self.(*RClass)) + if h := amTrailingHash(args); h != nil { + for _, k := range h.Keys { + v, _ := h.Get(k) + d.defaults[amKeyStr(k)] = v.ToS() + } + } + return object.NilV + }) + + // delivery_method = :test | a #deliver object. + sm("delivery_method=", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + choice := amArg(args) + vm.amValidateDelivery(choice) + vm.amDefOf(self.(*RClass)).delivery = choice + return choice + }) + sm("delivery_method", func(vm *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + if d := vm.amDefs[self.(*RClass)]; d != nil && d.delivery != nil { + return d.delivery + } + return object.Symbol("test") + }) + + // perform_deliveries= / raise_delivery_errors= gate delivery. + sm("perform_deliveries=", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + b := amArg(args).Truthy() + vm.amDefOf(self.(*RClass)).perform = &b + return amArg(args) + }) + sm("perform_deliveries", func(vm *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + if d := vm.amDefs[self.(*RClass)]; d != nil && d.perform != nil { + return object.Bool(*d.perform) + } + return object.Bool(true) + }) + sm("raise_delivery_errors=", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + b := amArg(args).Truthy() + vm.amDefOf(self.(*RClass)).raiseErrors = &b + return amArg(args) + }) + sm("raise_delivery_errors", func(vm *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + if d := vm.amDefs[self.(*RClass)]; d != nil && d.raiseErrors != nil { + return object.Bool(*d.raiseErrors) + } + return object.Bool(true) + }) + + // register_interceptor / register_observer add a delivery hook. + sm("register_interceptor", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + d := vm.amDefOf(self.(*RClass)) + d.interceptors = append(d.interceptors, amArg(args)) + return object.NilV + }) + sm("register_observer", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + d := vm.amDefOf(self.(*RClass)) + d.observers = append(d.observers, amArg(args)) + return object.NilV + }) + + // deliveries / deliveries= expose the shared test-delivery sink. + sm("deliveries", func(vm *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + return vm.amDeliveries + }) + sm("deliveries=", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + if arr, ok := amArg(args).(*object.Array); ok { + vm.amDeliveries = arr + } else { + vm.amDeliveries = object.NewArrayFromSlice(nil) + } + return vm.amDeliveries + }) + + // renderer= / enqueuer= inject the Action View and Active Job seams (wired to + // the bound go-ruby-actionview / go-ruby-activejob in the consolidated binary, + // or a stub in tests). + sm("renderer=", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + vm.amRenderer = amArg(args) + return vm.amRenderer + }) + sm("renderer", func(vm *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + if vm.amRenderer == nil { + return object.NilV + } + return vm.amRenderer + }) + sm("enqueuer=", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + vm.amEnqueuer = amArg(args) + return vm.amEnqueuer + }) + sm("enqueuer", func(vm *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + if vm.amEnqueuer == nil { + return object.NilV + } + return vm.amEnqueuer + }) +} + +// amDefineAction installs the class method that runs a mailer action: called on a +// mailer class it processes the action with the given arguments and returns the +// ActionMailer::MessageDelivery proxy. Defined once per name (a redefinition of +// the same action keeps the first, which dispatches to the current instance +// method anyway). +func (vm *VM) amDefineAction(cls *RClass, name string) { + if _, exists := cls.smethods[name]; exists { + return + } + cls.smethods[name] = &Method{name: name, owner: cls, native: func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + return vm.amProcess(self.(*RClass), name, args) + }} +} + +// registerActionMailerInstance installs the mailer instance surface a `def +// welcome` body runs against: mail(...), attachments and headers(hash). Each +// resolves the library *Mailer bound to the running action. +func (vm *VM) registerActionMailerInstance(base *RClass) { + base.define("mail", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + m := vm.amMailerFor(self) + if err := m.Mail(vm.amMailOptions(args)); err != nil { + return vm.amRaise(err) + } + return object.NilV + }) + base.define("attachments", func(vm *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return &ActionMailerAttachments{a: vm.amMailerFor(self).Attachments(), cls: vm.cActionMailerAttachments} + }) + base.define("headers", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + m := vm.amMailerFor(self) + if h := amTrailingHash(args); h != nil { + hdr := map[string]string{} + for _, k := range h.Keys { + v, _ := h.Get(k) + hdr[amKeyStr(k)] = v.ToS() + } + m.Headers(hdr) + } + return object.NilV + }) +} + +// registerActionMailerDelivery installs the ActionMailer::MessageDelivery proxy +// surface: deliver_now / deliver (immediate), deliver_later (through the enqueue +// seam) and message (the composed Mail::Message). A composition or delivery error +// captured on the delivery re-raises here as its Ruby exception. +func (vm *VM) registerActionMailerDelivery(cls *RClass) { + self := func(v object.Value) *ActionMailerDelivery { return v.(*ActionMailerDelivery) } + + deliverNow := func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + d := self(v).d + if err := d.DeliverNow(); err != nil { + return vm.amRaise(err) + } + msg, _ := d.Message() + return amMessageValue(msg) + } + cls.define("deliver_now", deliverNow) + cls.define("deliver", deliverNow) + + cls.define("deliver_later", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + if err := self(v).d.DeliverLater(); err != nil { + return vm.amRaise(err) + } + return object.NilV + }) + cls.define("message", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + msg, err := self(v).d.Message() + if err != nil { + return vm.amRaise(err) + } + return amMessageValue(msg) + }) +} + +// registerActionMailerAttachments installs the `attachments` proxy surface: +// attachments[name] = data (a regular attachment), attachments.inline (the inline +// sub-proxy) and attachments[name] (the stored bytes, or nil). +func (vm *VM) registerActionMailerAttachments(cls *RClass) { + self := func(v object.Value) *ActionMailerAttachments { return v.(*ActionMailerAttachments) } + + cls.define("[]=", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) < 2 { + raise("ArgumentError", "wrong number of arguments (given %d, expected 2)", len(args)) + } + a := self(v) + name := args[0].ToS() + data, ct := amAttachmentData(args[1]) + var at = a.a.Set(name, data) + if a.inline { + at = a.a.SetInline(name, data) + } + if ct != "" { + at.ContentType = ct + } + return args[1] + }) + cls.define("[]", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) == 0 { + raise("ArgumentError", "wrong number of arguments (given 0, expected 1)") + } + at := self(v).a.Get(args[0].ToS()) + if at == nil { + return object.NilV + } + return object.NewStringBytes(at.Data) + }) + cls.define("inline", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + a := self(v) + return &ActionMailerAttachments{a: a.a, inline: true, cls: a.cls} + }) +} + +// amArg returns the first argument, or Ruby nil when none was given. +func amArg(args []object.Value) object.Value { + if len(args) == 0 { + return object.NilV + } + return args[0] +} diff --git a/internal/vm/actionmailer_bind.go b/internal/vm/actionmailer_bind.go new file mode 100644 index 00000000..1ab6585e --- /dev/null +++ b/internal/vm/actionmailer_bind.go @@ -0,0 +1,518 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "errors" + + actionmailer "github.com/go-ruby-actionmailer/actionmailer" + mail "github.com/go-ruby-mail/mail" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// This file is the binding between rbgo's Ruby object graph and the pure-Go +// Action Mailer engine of github.com/go-ruby-actionmailer/actionmailer. The +// library owns message composition (headers, the multipart/alternative -> +// related -> mixed MIME assembly), the delivery-method registry and the +// interceptor/observer hooks; rbgo supplies the four interpreter-dependent seams +// it documents, each dispatched at the Ruby level and run INLINE under the GVL: +// +// - Action: a mailer method's body is Ruby, so the library's per-action closure +// sends the mailer action (welcome, …) to a freshly allocated Ruby mailer +// instance bound to the library *Mailer, mapping a raised Ruby exception into +// an amError the delivery proxy re-raises faithfully (amProcess / amInvokeAction). +// - RenderBody: body rendering is Action View, so the seam sends #render to the +// configured Ruby renderer (ActionMailer::Base.renderer=, wired to the bound +// go-ruby-actionview in the consolidated binary; an injected stub in tests); +// a nil renderer or a nil result means "no template for this format" and the +// format is skipped, exactly as a missing .text/.html template does. +// - DeliveryMethod: the default appends the composed message to the shared +// ActionMailer::Base.deliveries Array (the :test method); a Ruby object that +// responds to #deliver plugs in as a custom transport (delivery_method=). +// - EnqueueJob: deliver_later's Active Job seam sends #enqueue to the configured +// Ruby enqueuer (wired to the bound go-ruby-activejob in the consolidated +// binary; an injected stub in tests) with the delivery as its block; with no +// enqueuer the delivery runs inline. + +// amError carries a raised Ruby exception's class through the Go error the +// Action / RenderBody / delivery seams return, so the composed-message error the +// library captures on a MessageDelivery re-raises as the original Ruby exception +// (amRaise), mirroring how ActionMailer surfaces a composition/delivery failure +// from deliver_now / deliver_later / message. +type amError struct { + cls *RClass + msg string +} + +func (e *amError) Error() string { return e.msg } + +// ActionMailerDelivery is the lazy delivery proxy `MyMailer.welcome(user)` +// returns — a Ruby ActionMailer::MessageDelivery wrapping the library +// *MessageDelivery — responding to deliver_now / deliver_later / message. +type ActionMailerDelivery struct { + d *actionmailer.MessageDelivery + cls *RClass +} + +func (d *ActionMailerDelivery) ToS() string { return "#" } +func (d *ActionMailerDelivery) Inspect() string { return d.ToS() } +func (d *ActionMailerDelivery) Truthy() bool { return true } + +// ActionMailerAttachments is the mailer's `attachments` proxy (mirroring +// Mail::AttachmentsList): attachments[name] = data adds a regular attachment, +// attachments.inline[name] = data adds an inline one. It wraps the library +// *Attachments of the action's *Mailer; inline records which sub-proxy this is. +type ActionMailerAttachments struct { + a *actionmailer.Attachments + inline bool + cls *RClass +} + +func (a *ActionMailerAttachments) ToS() string { return "#" } +func (a *ActionMailerAttachments) Inspect() string { return a.ToS() } +func (a *ActionMailerAttachments) Truthy() bool { return true } + +// --- class-level declaration model ------------------------------------------ + +// amDef is the class-level Action Mailer configuration a mailer class accumulates +// as its body runs the DSL (default, delivery_method=, perform_deliveries=, +// raise_delivery_errors=, register_interceptor / register_observer). Each mailer +// class owns one; amBuildBase flattens a class and its ancestors' defs into a +// live *actionmailer.Base at delivery time, so a subclass inherits its parents' +// defaults, delivery method and hooks (ancestor first, subclass overrides). +type amDef struct { + defaults map[string]string + delivery object.Value // :test symbol or a Ruby object responding to #deliver; nil = inherit/default + perform *bool + raiseErrors *bool + interceptors []object.Value + observers []object.Value +} + +// amDefOf returns the amDef for a mailer class, creating it on first use. +func (vm *VM) amDefOf(cls *RClass) *amDef { + if vm.amDefs == nil { + vm.amDefs = map[*RClass]*amDef{} + } + d, ok := vm.amDefs[cls] + if !ok { + d = &amDef{defaults: map[string]string{}} + vm.amDefs[cls] = d + } + return d +} + +// amBuildBase assembles a *actionmailer.Base for a mailer class from its +// declaration chain (ActionMailer::Base-most ancestor first, the mailer last so +// it overrides), wiring the RenderBody and EnqueueJob Ruby-dispatch seams and the +// delivery method. It is built fresh per delivery, so the latest class config +// always applies; Now is left nil so composed output carries no non-deterministic +// Date header. +func (vm *VM) amBuildBase(cls *RClass) *actionmailer.Base { + b := actionmailer.New(cls.name) + b.Now = nil + b.RenderBody = vm.amRenderBody() + b.EnqueueJob = vm.amEnqueueJob() + + var chain []*RClass + for c := cls; c != nil && c != vm.cObject; c = c.super { + chain = append(chain, c) + } + var delivery object.Value + for i := len(chain) - 1; i >= 0; i-- { + d := vm.amDefs[chain[i]] + if d == nil { + continue + } + for k, v := range d.defaults { + b.Default(k, v) + } + if d.delivery != nil { + delivery = d.delivery + } + if d.perform != nil { + b.PerformDeliveries = *d.perform + } + if d.raiseErrors != nil { + b.RaiseDeliveryErrors = *d.raiseErrors + } + for _, in := range d.interceptors { + b.RegisterInterceptor(&amRubyInterceptor{vm: vm, obj: in}) + } + for _, ob := range d.observers { + b.RegisterObserver(&amRubyObserver{vm: vm, obj: ob}) + } + } + b.DeliveryMethod = vm.amDeliveryMethod(delivery) + return b +} + +// --- action dispatch -------------------------------------------------------- + +// amProcess runs a mailer action: it builds the class's *actionmailer.Base, +// registers the Ruby action seam and processes the action with the call's +// arguments, returning the Ruby ActionMailer::MessageDelivery proxy. The library +// composes the message eagerly (running the Ruby action body inline here) and +// captures any composition error on the delivery for deliver_now/message to raise. +func (vm *VM) amProcess(cls *RClass, action string, args []object.Value) object.Value { + b := vm.amBuildBase(cls) + b.Register(action, func(m *actionmailer.Mailer, params ...any) error { + inst := &RObject{class: cls, ivars: map[string]object.Value{}} + vm.amBind(inst, m) + rubyArgs := make([]object.Value, len(params)) + for i, p := range params { + rubyArgs[i] = p.(object.Value) + } + if aerr := vm.amInvokeAction(inst, action, rubyArgs); aerr != nil { + return aerr + } + return nil + }) + goArgs := make([]any, len(args)) + for i, a := range args { + goArgs[i] = a + } + return &ActionMailerDelivery{d: b.Process(action, goArgs...), cls: vm.cActionMailerDelivery} +} + +// amBind associates a Ruby mailer instance with the library *Mailer of its +// running action, so the mail / attachments / headers instance methods resolve +// the composer for the current invocation. +func (vm *VM) amBind(inst *RObject, m *actionmailer.Mailer) { + if vm.amMailerOf == nil { + vm.amMailerOf = map[*RObject]*actionmailer.Mailer{} + } + vm.amMailerOf[inst] = m +} + +// amMailerFor returns the library *Mailer bound to a mailer instance, raising +// when mail/attachments/headers are called outside a mailer action. +func (vm *VM) amMailerFor(self object.Value) *actionmailer.Mailer { + if o, ok := self.(*RObject); ok { + if m := vm.amMailerOf[o]; m != nil { + return m + } + } + raise("RuntimeError", "no mailer action in progress") + return nil +} + +// amInvokeAction sends the action to the mailer instance, recovering a raised +// Ruby exception into an amError so the library captures it on the delivery +// (mirroring an action that raises during composition). A non-Ruby Go panic +// propagates. +func (vm *VM) amInvokeAction(inst *RObject, action string, args []object.Value) (aerr *amError) { + defer func() { + if r := recover(); r != nil { + e := r.(RubyError) + aerr = &amError{cls: vm.minitestRaisedClass(e), msg: e.Message} + } + }() + vm.send(inst, action, args, nil) + return nil +} + +// amProtect runs f, recovering a raised Ruby exception into an amError so a seam +// invoked deep inside the library (RenderBody, a Ruby delivery method) surfaces +// the failure through the library's error path rather than unwinding across it. +func (vm *VM) amProtect(f func() object.Value) (res object.Value, aerr *amError) { + defer func() { + if r := recover(); r != nil { + e := r.(RubyError) + aerr = &amError{cls: vm.minitestRaisedClass(e), msg: e.Message} + } + }() + return f(), nil +} + +// amRaise re-raises an error the library surfaced as the faithful Ruby exception: +// a seam failure carries its original Ruby class through amError; any other +// (a library sentinel such as ErrNoDeliveryMethod) raises as a RuntimeError. +func (vm *VM) amRaise(err error) object.Value { + var ae *amError + if errors.As(err, &ae) { + return raise(ae.cls.name, "%s", ae.Error()) + } + return raise("RuntimeError", "%s", err.Error()) +} + +// --- RenderBody seam -------------------------------------------------------- + +// amRenderBody builds the body-rendering seam: it sends #render to the configured +// Ruby renderer with the mailer/action/format/locals, returning the rendered +// String. A nil renderer or a nil #render result signals ErrNoTemplate, so that +// format is skipped; a raise during rendering surfaces as an amError the delivery +// proxy re-raises. +func (vm *VM) amRenderBody() actionmailer.RenderBody { + return func(mailer, action, format string, locals map[string]any) (string, error) { + if vm.amRenderer == nil { + return "", actionmailer.ErrNoTemplate + } + h := object.NewHash() + h.Set(object.Symbol("mailer"), object.NewString(mailer)) + h.Set(object.Symbol("action"), object.NewString(action)) + h.Set(object.Symbol("format"), object.NewString(format)) + h.Set(object.Symbol("locals"), amLocalsToRuby(locals)) + res, aerr := vm.amProtect(func() object.Value { + return vm.send(vm.amRenderer, "render", []object.Value{h}, nil) + }) + if aerr != nil { + return "", aerr + } + if object.IsNil(res) { + return "", actionmailer.ErrNoTemplate + } + return res.ToS(), nil + } +} + +// amLocalsToRuby maps the render seam's locals back into a Ruby Hash (the values +// are the Ruby Values captured from the mail(locals:) call). +func amLocalsToRuby(locals map[string]any) object.Value { + h := object.NewHash() + for k, v := range locals { + h.Set(object.NewString(k), v.(object.Value)) + } + return h +} + +// --- EnqueueJob seam -------------------------------------------------------- + +// amEnqueueJob builds the deliver_later Active Job seam: with a configured Ruby +// enqueuer it sends #enqueue with the delivery as its block (the enqueuer may run +// it now or defer it); with no enqueuer the delivery runs inline. A delivery +// error raised by the inline path propagates. +func (vm *VM) amEnqueueJob() func(func() error) error { + return func(job func() error) error { + if vm.amEnqueuer == nil { + return job() + } + blk := &Proc{native: func(_ *VM, _ []object.Value) object.Value { + if err := job(); err != nil { + return vm.amRaise(err) + } + return object.NilV + }} + vm.send(vm.amEnqueuer, "enqueue", nil, blk) + return nil + } +} + +// --- delivery method + interceptor/observer seams --------------------------- + +// amValidateDelivery checks a delivery_method= value at set time (so amDeliveryMethod +// only ever sees a valid choice): :test is the built-in test method, any other +// Symbol is unsupported, and a non-Symbol must respond to #deliver. +func (vm *VM) amValidateDelivery(choice object.Value) { + if sym, ok := choice.(object.Symbol); ok { + if string(sym) != "test" { + raise("ArgumentError", "unsupported delivery_method %q (only :test or a #deliver object)", string(sym)) + } + return + } + if !vm.respondsTo(choice, "deliver") { + raise("ArgumentError", "delivery_method must be :test or respond to #deliver") + } +} + +// amDeliveryMethod resolves a (validated) delivery-method choice: nil or :test +// appends to the shared ActionMailer::Base.deliveries Array; any other value is a +// Ruby object responding to #deliver, plugged in as a custom transport. +func (vm *VM) amDeliveryMethod(choice object.Value) actionmailer.DeliveryMethod { + if choice == nil { + return &amArrayDelivery{vm: vm} + } + if sym, ok := choice.(object.Symbol); ok && string(sym) == "test" { + return &amArrayDelivery{vm: vm} + } + return &amRubyDelivery{vm: vm, obj: choice} +} + +// amArrayDelivery is the :test delivery method: it appends the delivered message +// to the shared ActionMailer::Base.deliveries Array (read through vm so a +// deliveries= reset is honoured). +type amArrayDelivery struct{ vm *VM } + +func (d *amArrayDelivery) Deliver(m *mail.Message) error { + d.vm.amDeliveries.Elems = append(d.vm.amDeliveries.Elems, amMessageValue(m)) + return nil +} + +// amRubyDelivery wraps a Ruby delivery object (delivery_method= obj): Deliver +// sends #deliver with the composed Mail::Message, mapping a raise to an amError. +type amRubyDelivery struct { + vm *VM + obj object.Value +} + +func (d *amRubyDelivery) Deliver(m *mail.Message) error { + _, aerr := d.vm.amProtect(func() object.Value { + return d.vm.send(d.obj, "deliver", []object.Value{amMessageValue(m)}, nil) + }) + if aerr != nil { + return aerr + } + return nil +} + +// amRubyInterceptor wraps a Ruby interceptor (register_interceptor obj): it sends +// #delivering_email with the message just before delivery. +type amRubyInterceptor struct { + vm *VM + obj object.Value +} + +func (i *amRubyInterceptor) DeliveringEmail(m *mail.Message) { + i.vm.send(i.obj, "delivering_email", []object.Value{amMessageValue(m)}, nil) +} + +// amRubyObserver wraps a Ruby observer (register_observer obj): it sends +// #delivered_email with the message just after delivery. +type amRubyObserver struct { + vm *VM + obj object.Value +} + +func (o *amRubyObserver) DeliveredEmail(m *mail.Message) { + o.vm.send(o.obj, "delivered_email", []object.Value{amMessageValue(m)}, nil) +} + +// amMessageValue wraps a library *mail.Message as the Ruby Mail::Message value the +// Mail module already binds, so a delivered message is inspectable in Ruby. +func amMessageValue(m *mail.Message) object.Value { + return &MailMessage{m: mailMsg{m: m}} +} + +// --- mail(...) option + attachment parsing ---------------------------------- + +// amMailOptions maps the keyword Hash of a Ruby mail(...) call into the library +// MailOptions: the recipient lists (to/cc/bcc/reply_to) accept a String or an +// Array, from/subject/content_type/body/formats/locals map to their fields, and +// any other key becomes an extra header. +func (vm *VM) amMailOptions(args []object.Value) actionmailer.MailOptions { + opts := actionmailer.MailOptions{} + h := amTrailingHash(args) + if h == nil { + return opts + } + for _, k := range h.Keys { + v, _ := h.Get(k) + switch amKeyStr(k) { + case "to": + opts.To = amStrList(v) + case "cc": + opts.Cc = amStrList(v) + case "bcc": + opts.Bcc = amStrList(v) + case "reply_to": + opts.ReplyTo = amStrList(v) + case "from": + opts.From = v.ToS() + case "subject": + opts.Subject = v.ToS() + case "content_type": + opts.ContentType = v.ToS() + case "body": + opts.Body = v.ToS() + case "formats": + opts.Formats = amStrList(v) + case "locals": + opts.Locals = amLocalsFromRuby(v) + default: + if opts.Headers == nil { + opts.Headers = map[string]string{} + } + opts.Headers[amKeyStr(k)] = v.ToS() + } + } + return opts +} + +// amLocalsFromRuby maps a Ruby locals Hash into the render seam's locals map, +// keying by the String form of each key and carrying the Ruby Value through. +func amLocalsFromRuby(v object.Value) map[string]any { + h, ok := v.(*object.Hash) + if !ok { + return nil + } + out := map[string]any{} + for _, k := range h.Keys { + val, _ := h.Get(k) + out[amKeyStr(k)] = val + } + return out +} + +// amAttachmentData reads the value of an `attachments[name] = …` assignment: a +// bare String is the content; a Hash carries :content (or :data/:body) and an +// optional :content_type / :mime_type override. +func amAttachmentData(v object.Value) (data []byte, contentType string) { + if h, ok := v.(*object.Hash); ok { + if c, ok := amHashFetch(h, "content", "data", "body"); ok { + data = amBytes(c) + } + if ct, ok := amHashFetch(h, "content_type", "mime_type"); ok { + contentType = ct.ToS() + } + return data, contentType + } + return amBytes(v), "" +} + +// amHashFetch returns the first present of the given symbol-or-string keys. +func amHashFetch(h *object.Hash, keys ...string) (object.Value, bool) { + for _, k := range keys { + if v, ok := h.Get(object.Symbol(k)); ok { + return v, true + } + if v, ok := h.Get(object.NewString(k)); ok { + return v, true + } + } + return nil, false +} + +// amTrailingHash returns the trailing keyword Hash of an argument list, or nil. +func amTrailingHash(args []object.Value) *object.Hash { + if len(args) == 0 { + return nil + } + if h, ok := args[len(args)-1].(*object.Hash); ok { + return h + } + return nil +} + +// amStrList coerces a recipient value to a list of Strings: an Array yields each +// element's String, any other value yields a single-element list. +func amStrList(v object.Value) []string { + if arr, ok := v.(*object.Array); ok { + out := make([]string, len(arr.Elems)) + for i, e := range arr.Elems { + out[i] = e.ToS() + } + return out + } + return []string{v.ToS()} +} + +// amKeyStr renders a Hash/option key as its String form (a Symbol without the +// colon). +func amKeyStr(k object.Value) string { + if s, ok := k.(object.Symbol); ok { + return string(s) + } + return k.ToS() +} + +// amBytes returns the raw bytes of a value: a String's content verbatim (so +// binary attachment data round-trips), any other value its String form. +func amBytes(v object.Value) []byte { + if s, ok := v.(*object.String); ok { + return s.Bytes() + } + return []byte(v.ToS()) +} diff --git a/internal/vm/actionmailer_test.go b/internal/vm/actionmailer_test.go new file mode 100644 index 00000000..8e8b6d17 --- /dev/null +++ b/internal/vm/actionmailer_test.go @@ -0,0 +1,494 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm_test + +import "testing" + +// amSrc prefixes a program with require "action_mailer". +func amSrc(body string) string { return "require \"action_mailer\"\n" + body } + +// amRendererStub is a program fragment installing a renderer stub that renders a +// text and an html body from the :name local. +const amRendererStub = ` +stub = Object.new +def stub.render(opts) + n = opts[:locals]["name"] + case opts[:format] + when "text" then "Hi #{n}" + when "html" then "

Hi #{n}

" + end +end +ActionMailer::Base.renderer = stub +` + +func amCheck(t *testing.T, src, want string) { + t.Helper() + if got := eval(t, amSrc(src)); got != want { + t.Errorf("src=%q\n got=%q\nwant=%q", src, got, want) + } +} + +// TestActionMailerRequire covers feature registration and the constant tree. +func TestActionMailerRequire(t *testing.T) { + cases := []struct{ src, want string }{ + {`p require "action_mailer"`, "true\n"}, + {`require "action_mailer"; p require "action_mailer"`, "false\n"}, + {`p require "actionmailer"`, "true\n"}, + {`require "action_mailer"; p ActionMailer.is_a?(Module)`, "true\n"}, + {`require "action_mailer"; p ActionMailer::Base.is_a?(Class)`, "true\n"}, + {`require "action_mailer"; p ActionMailer::MessageDelivery.is_a?(Class)`, "true\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// TestActionMailerCompose covers the end-to-end compose+deliver flow: the default +// from, the rendered multipart/alternative body, deliver_now returning the +// message, the shared deliveries Array and #message. +func TestActionMailerCompose(t *testing.T) { + amCheck(t, amRendererStub+` +class UserMailer < ActionMailer::Base + default from: "notifications@example.com" + def welcome(name) + mail(to: "ada@example.com", subject: "Welcome", locals: { name: name }) + end +end +md = UserMailer.welcome("Ada") +p md.class.name +msg = md.deliver_now +p msg.subject +p msg.to +p msg.from +p ActionMailer::Base.deliveries.length +e = md.message.encoded +puts e.include?("multipart/alternative") +puts e.include?("Hi Ada") +puts e.include?("

Hi Ada

") +`, "\"ActionMailer::MessageDelivery\"\n\"Welcome\"\n\"ada@example.com\"\n\"notifications@example.com\"\n1\ntrue\ntrue\ntrue\n") +} + +// TestActionMailerRecipientsAndHeaders covers recipient lists (String and Array), +// an explicit from, a custom header from an unknown mail(...) key and the +// headers(hash) instance method. +func TestActionMailerRecipientsAndHeaders(t *testing.T) { + amCheck(t, amRendererStub+` +class M < ActionMailer::Base + def multi + headers("X-Extra" => "e") + mail(from: "s@x.com", to: ["a@x.com", "b@x.com"], cc: "c@x.com", + bcc: ["d@x.com"], reply_to: "r@x.com", subject: "Hi", + "X-Custom" => "v", locals: {}) + end +end +msg = M.multi.deliver_now +p msg.to +p msg.cc +e = msg.encoded +puts e.include?("X-Custom: v") +puts e.include?("X-Extra: e") +puts e.include?("Reply-To: r@x.com") +`, "[\"a@x.com\", \"b@x.com\"]\n\"c@x.com\"\ntrue\ntrue\ntrue\n") +} + +// TestActionMailerExplicitBody covers mail(body:, content_type:) which bypasses +// the RenderBody seam, and a mailer with no renderer configured. +func TestActionMailerExplicitBody(t *testing.T) { + amCheck(t, ` +class Plain < ActionMailer::Base + def note + mail(to: "a@x.com", subject: "N", body: "hello", content_type: "text/plain") + end +end +e = Plain.note.deliver_now.encoded +puts e.include?("hello") +puts e.include?("multipart") == false +`, "true\ntrue\n") +} + +// TestActionMailerNoTemplate covers the RenderBody seam skipping a format the +// renderer has no template for (nil result) and rendering only the other. +func TestActionMailerNoTemplate(t *testing.T) { + amCheck(t, ` +stub = Object.new +def stub.render(opts) + opts[:format] == "text" ? "only text" : nil +end +ActionMailer::Base.renderer = stub +class M < ActionMailer::Base + def go; mail(to: "a@x.com", subject: "S", locals: {}); end +end +e = M.go.deliver_now.encoded +puts e.include?("only text") +puts e.include?("multipart/alternative") == false +`, "true\ntrue\n") +} + +// TestActionMailerFormats covers the mail(formats:) option restricting rendering +// to a single format. +func TestActionMailerFormats(t *testing.T) { + amCheck(t, amRendererStub+` +class M < ActionMailer::Base + def go; mail(to: "a@x.com", subject: "S", formats: ["text"], locals: {name: "Zo"}); end +end +e = M.go.deliver_now.encoded +puts e.include?("Hi Zo") +puts e.include?("

") == false +`, "true\ntrue\n") +} + +// TestActionMailerAttachments covers regular attachments (multipart/mixed), inline +// attachments (multipart/related + Content-ID), the hash form with a content_type +// override and the attachments[name] reader (present and absent). +func TestActionMailerAttachments(t *testing.T) { + amCheck(t, amRendererStub+` +class M < ActionMailer::Base + def go + attachments["a.txt"] = "plain" + attachments.inline["logo.png"] = "PNGDATA" + attachments["data.bin"] = { content: "raw", mime_type: "application/x-thing" } + p attachments["a.txt"] + p attachments["missing"] + mail(to: "a@x.com", subject: "S", locals: {name: "Q"}) + end +end +e = M.go.deliver_now.encoded +puts e.include?("multipart/mixed") +puts e.include?("multipart/related") +puts e.include?("Content-ID: ") +puts e.include?("application/x-thing") +`, "\"plain\"\nnil\ntrue\ntrue\ntrue\ntrue\n") +} + +// TestActionMailerDeliverLater covers deliver_later run inline (no enqueuer), via +// an enqueuer stub that yields (runs the delivery) and via one that defers it. +func TestActionMailerDeliverLater(t *testing.T) { + amCheck(t, amRendererStub+` +class M < ActionMailer::Base + def go; mail(to: "a@x.com", subject: "S", locals: {name: "L"}); end +end +# inline (no enqueuer) +M.go.deliver_later +p ActionMailer::Base.deliveries.length + +# enqueuer that runs immediately +runner = Object.new +def runner.enqueue; yield; end +ActionMailer::Base.enqueuer = runner +M.go.deliver_later +p ActionMailer::Base.deliveries.length + +# enqueuer that defers (never yields) +defer = Object.new +def defer.enqueue; end +ActionMailer::Base.enqueuer = defer +M.go.deliver_later +p ActionMailer::Base.deliveries.length +p ActionMailer::Base.enqueuer.equal?(defer) +`, "1\n2\n2\ntrue\n") +} + +// TestActionMailerInterceptorsObservers covers register_interceptor and +// register_observer hooks firing around delivery. +func TestActionMailerInterceptorsObservers(t *testing.T) { + amCheck(t, amRendererStub+` +$log = [] +icept = Object.new +def icept.delivering_email(m); $log << "before:#{m.subject}"; end +obs = Object.new +def obs.delivered_email(m); $log << "after:#{m.subject}"; end +class M < ActionMailer::Base + def go; mail(to: "a@x.com", subject: "S", locals: {name: "I"}); end +end +ActionMailer::Base.register_interceptor(icept) +ActionMailer::Base.register_observer(obs) +M.go.deliver_now +p $log +`, "[\"before:S\", \"after:S\"]\n") +} + +// TestActionMailerDeliveryMethod covers delivery_method= with :test, the reader, +// a custom #deliver object, and the unsupported-symbol / non-#deliver raises. +func TestActionMailerDeliveryMethod(t *testing.T) { + amCheck(t, amRendererStub+` +$sent = [] +custom = Object.new +def custom.deliver(m); $sent << m.subject; end +class M < ActionMailer::Base + def go; mail(to: "a@x.com", subject: "S", locals: {name: "D"}); end +end +p M.delivery_method +M.delivery_method = :test +p M.delivery_method +M.delivery_method = custom +M.go.deliver_now +p $sent +p ActionMailer::Base.deliveries.length +begin + M.delivery_method = :smtp + M.go.deliver_now +rescue ArgumentError => e + puts "bad-sym" +end +begin + M.delivery_method = Object.new + M.go.deliver_now +rescue ArgumentError + puts "bad-obj" +end +`, ":test\n:test\n[\"S\"]\n0\nbad-sym\nbad-obj\n") +} + +// TestActionMailerPerformAndRaiseFlags covers perform_deliveries= false (delivery +// skipped) and raise_delivery_errors gating a delivery-method error. +func TestActionMailerPerformAndRaiseFlags(t *testing.T) { + amCheck(t, amRendererStub+` +class Quiet < ActionMailer::Base + def go; mail(to: "a@x.com", subject: "S", locals: {name: "P"}); end +end +p Quiet.perform_deliveries +p Quiet.raise_delivery_errors +Quiet.perform_deliveries = false +Quiet.go.deliver_now +p ActionMailer::Base.deliveries.length + +boom = Object.new +def boom.deliver(m); raise "smtp down"; end +class Loud < ActionMailer::Base + def go; mail(to: "a@x.com", subject: "S", locals: {name: "R"}); end +end +Loud.delivery_method = boom +begin + Loud.go.deliver_now +rescue RuntimeError => e + puts "raised:#{e.message}" +end +Loud.raise_delivery_errors = false +p Loud.raise_delivery_errors +Loud.go.deliver_now +puts "swallowed" +`, "true\ntrue\n0\nraised:smtp down\nfalse\nswallowed\n") +} + +// TestActionMailerDeliveriesReset covers deliveries= replacing the sink (with an +// Array and with a non-Array reset) and the renderer/enqueuer nil readers. +func TestActionMailerDeliveriesReset(t *testing.T) { + amCheck(t, ` +p ActionMailer::Base.renderer +p ActionMailer::Base.enqueuer +class M < ActionMailer::Base + def go; mail(to: "a@x.com", subject: "S", body: "b"); end +end +M.go.deliver_now +p ActionMailer::Base.deliveries.length +ActionMailer::Base.deliveries = [] +p ActionMailer::Base.deliveries.length +M.go.deliver_now +p ActionMailer::Base.deliveries.length +ActionMailer::Base.deliveries = nil +p ActionMailer::Base.deliveries.length +`, "nil\nnil\n1\n0\n1\n0\n") +} + +// TestActionMailerErrors covers the composition error paths: an action that +// raises, an action that never calls mail, a renderer that raises, and calling +// mail outside a mailer action. +func TestActionMailerErrors(t *testing.T) { + amCheck(t, ` +class Boom < ActionMailer::Base + def go(x); raise ArgumentError, "no #{x}"; end + def empty; @x = 1; end +end +begin + Boom.go("way").deliver_now +rescue ArgumentError => e + puts "action:#{e.message}" +end +begin + Boom.empty.message +rescue => e + puts "nomail" +end +begin + Boom.new.send(:mail, to: "a@x.com") +rescue RuntimeError + puts "outside" +end + +bad = Object.new +def bad.render(opts); raise "tmpl broke"; end +ActionMailer::Base.renderer = bad +class R < ActionMailer::Base + def go; mail(to: "a@x.com", subject: "S", locals: {}); end +end +begin + R.go.deliver_now +rescue RuntimeError => e + puts "render:#{e.message}" +end +`, "action:no way\nnomail\noutside\nrender:tmpl broke\n") +} + +// TestActionMailerInheritance covers a subclass inheriting the parent's default +// and a parent-defined action. +func TestActionMailerInheritance(t *testing.T) { + amCheck(t, amRendererStub+` +class AppMailer < ActionMailer::Base + default from: "app@x.com" + def base_action; mail(to: "a@x.com", subject: "Base", locals: {name: "N"}); end +end +class ChildMailer < AppMailer + def child_action; mail(to: "b@x.com", subject: "Child", locals: {name: "C"}); end +end +m1 = ChildMailer.child_action.deliver_now +p m1.from +p m1.subject +m2 = ChildMailer.base_action.deliver_now +p m2.subject +`, "\"app@x.com\"\n\"Child\"\n\"Base\"\n") +} + +// TestActionMailerValueMethods covers the ToS/Inspect/Truthy of the delivery and +// attachments Ruby value wrappers. +func TestActionMailerValueMethods(t *testing.T) { + amCheck(t, ` +class M < ActionMailer::Base + def go + a = attachments + puts a.inspect + puts a.to_s + puts(a ? "at" : "af") + mail(to: "a@x.com", subject: "S", body: "b") + end +end +md = M.go +p md +puts md.to_s +puts(md ? "t" : "f") +`, "#\n#\nat\n#\n#\nt\n") +} + +// TestActionMailerRedefine covers the method_added hook seeing a redefined action +// name (the class method is installed once). +func TestActionMailerRedefine(t *testing.T) { + amCheck(t, ` +class M < ActionMailer::Base + def go; mail(to: "a@x.com", subject: "One", body: "b"); end + def go; mail(to: "a@x.com", subject: "Two", body: "b"); end +end +p M.go.deliver_now.subject +`, "\"Two\"\n") +} + +// TestActionMailerZeroArgHooks covers registering a hook with no argument (the +// amArg empty path) without delivering. +func TestActionMailerZeroArgHooks(t *testing.T) { + amCheck(t, ` +p ActionMailer::Base.send(:register_interceptor) +`, "nil\n") +} + +// TestActionMailerNoRendererEmptyBody covers the RenderBody seam with no renderer +// configured and no explicit body: both formats skip, yielding an empty body. +func TestActionMailerNoRendererEmptyBody(t *testing.T) { + amCheck(t, ` +class M < ActionMailer::Base + def go; mail(to: "a@x.com", subject: "S"); end +end +e = M.go.deliver_now.encoded +puts e.include?("Subject: S") +puts e.include?("multipart") == false +`, "true\ntrue\n") +} + +// TestActionMailerTwoCustomHeaders covers the second-custom-header branch of the +// mail(...) option parser (Headers map already allocated). +func TestActionMailerTwoCustomHeaders(t *testing.T) { + amCheck(t, ` +class M < ActionMailer::Base + def go; mail(to: "a@x.com", subject: "S", body: "b", "X-A" => "1", "X-B" => "2"); end +end +e = M.go.deliver_now.encoded +puts e.include?("X-A: 1") +puts e.include?("X-B: 2") +`, "true\ntrue\n") +} + +// TestActionMailerOptionEdges covers non-Hash locals, a String attachment value +// coerced from a non-String, the attachments hash form with string keys, the +// attachments[]=/[] argument-count raises, mail() with no options and a +// non-Hash headers argument. +func TestActionMailerOptionEdges(t *testing.T) { + amCheck(t, ` +class M < ActionMailer::Base + def go + attachments["n.bin"] = 12345 + attachments["s.dat"] = { "content" => "x", "content_type" => "application/x-str" } + attachments["empty.dat"] = { "note" => "no content here" } + begin; attachments.send(:[]=, "only"); rescue ArgumentError; puts "set-args"; end + begin; attachments.send(:[]); rescue ArgumentError; puts "get-args"; end + mail(to: "a@x.com", subject: "S", body: "b", locals: "not-a-hash") + end +end +e = M.go.deliver_now.encoded +puts e.include?("application/x-str") +puts e.include?("n.bin") + +class N < ActionMailer::Base + def go + headers("just-a-string") + mail + end +end +N.go.deliver_now +puts "ok" +`, "set-args\nget-args\ntrue\ntrue\nok\n") +} + +// TestActionMailerDeliverLaterErrors covers deliver_later surfacing a delivery +// error inline and through an enqueuer that runs the deferred delivery. +func TestActionMailerDeliverLaterErrors(t *testing.T) { + amCheck(t, ` +boom = Object.new +def boom.deliver(m); raise "down"; end +class M < ActionMailer::Base + def go; mail(to: "a@x.com", subject: "S", body: "b"); end +end +M.delivery_method = boom +begin + M.go.deliver_later +rescue RuntimeError => e + puts "inline:#{e.message}" +end + +runner = Object.new +def runner.enqueue; yield; end +ActionMailer::Base.enqueuer = runner +begin + M.go.deliver_later +rescue RuntimeError => e + puts "enq:#{e.message}" +end +`, "inline:down\nenq:down\n") +} + +// TestActionMailerReaderBranches covers the renderer reader when set, the :test +// delivery method reaching the composer, and the perform_deliveries reader after +// it is declared. +func TestActionMailerReaderBranches(t *testing.T) { + amCheck(t, amRendererStub+` +class M < ActionMailer::Base + def go; mail(to: "a@x.com", subject: "S", locals: {name: "Z"}); end +end +puts ActionMailer::Base.renderer.nil? +M.delivery_method = :test +M.go.deliver_now +p ActionMailer::Base.deliveries.length +M.perform_deliveries = false +p M.perform_deliveries +`, "false\n1\nfalse\n") +} diff --git a/internal/vm/builtins.go b/internal/vm/builtins.go index 6721fcd8..c52b7abf 100644 --- a/internal/vm/builtins.go +++ b/internal/vm/builtins.go @@ -469,6 +469,7 @@ func (vm *VM) bootstrap() { vm.registerPublicSuffix() // PublicSuffix module (go-ruby-public-suffix backend); needs StandardError for PublicSuffix::Error tree vm.registerMIMETypes() // MIME::Types module (go-ruby-mime-types backend) vm.registerMail() // Mail module (go-ruby-mail backend) + vm.registerActionMailer() // ActionMailer::Base subclass DSL (default/delivery_method/register_interceptor/register_observer) + MessageDelivery proxy (deliver_now/deliver_later/message) + mail/attachments/headers (require "action_mailer"), backed by go-ruby-actionmailer; the mailer-action body, RenderBody (Action View), delivery method and EnqueueJob (Active Job) are Ruby-dispatch seams run INLINE under the GVL; needs the Mail message surface, so registered after registerMail vm.registerFaker() // Faker module (go-ruby-faker backend); needs Random for the seed contract vm.registerCommonmark() // Commonmark.render_html / String#to_html (require "commonmark"), backed by go-ruby-commonmark vm.registerMustache() // Mustache.render + Mustache view class (require "mustache"); needs StandardError for Mustache::Error diff --git a/internal/vm/object_model.go b/internal/vm/object_model.go index 297665ed..a1c4ee72 100644 --- a/internal/vm/object_model.go +++ b/internal/vm/object_model.go @@ -1132,6 +1132,10 @@ func (vm *VM) classOf(v object.Value) *RClass { return vm.consts["Mail::Body"].(*RClass) case *MailField: return vm.consts["Mail::Field"].(*RClass) + case *ActionMailerDelivery: + return x.cls + case *ActionMailerAttachments: + return x.cls case *FileStat: return vm.cFileStat case *BigDecimal: diff --git a/internal/vm/require.go b/internal/vm/require.go index 1ce3dd7d..7cae302e 100644 --- a/internal/vm/require.go +++ b/internal/vm/require.go @@ -83,6 +83,7 @@ var providedFeatures = map[string]bool{ "hanami": true, "hanami/router": true, "hanami/action": true, "warden": true, "omniauth": true, "public_suffix": true, "mime/types": true, "mail": true, "faker": true, + "action_mailer": true, "actionmailer": true, "rqrcode": true, "dotenv": true, "hcl2": true, "kramdown": true, "liquid": true, "rouge": true, "slim": true, "haml": true, "dry/types": true, "dry-types": true, diff --git a/internal/vm/vm.go b/internal/vm/vm.go index 94d4017c..9681e17a 100644 --- a/internal/vm/vm.go +++ b/internal/vm/vm.go @@ -19,6 +19,7 @@ import ( "strings" "sync" + actionmailer "github.com/go-ruby-actionmailer/actionmailer" activejob "github.com/go-ruby-activejob/activejob" activestorage "github.com/go-ruby-activestorage/activestorage" inflector "github.com/go-ruby-activesupport/activesupport/inflector" @@ -279,6 +280,14 @@ type VM struct { ajStack []*RObject // ActiveJob: instance stack the inline #perform seam reads ajLastResult object.Value // ActiveJob: last #perform return value (perform_now's result) ajArgs *activejob.Arguments // ActiveJob: module-level ActiveJob::Arguments serializer (GlobalID seam wired in registerActiveJob) + cActionMailerBase *RClass // ActionMailer::Base, the superclass every mailer subclasses (require "action_mailer"), backed by go-ruby-actionmailer + cActionMailerDelivery *RClass // ActionMailer::MessageDelivery, the lazy proxy a mailer action returns + cActionMailerAttachments *RClass // the internal `attachments` proxy class (Mail::AttachmentsList analogue) + amDefs map[*RClass]*amDef // ActionMailer: per-mailer-class default/delivery/hook declarations, merged down the chain at delivery time + amDeliveries *object.Array // ActionMailer: the shared ActionMailer::Base.deliveries Array the :test delivery method appends to + amMailerOf map[*RObject]*actionmailer.Mailer // ActionMailer: mailer instance -> library *Mailer of its running action (the mail/attachments/headers seam) + amRenderer object.Value // ActionMailer: the Ruby renderer the RenderBody seam sends #render to (Action View wiring / test stub) + amEnqueuer object.Value // ActionMailer: the Ruby enqueuer the deliver_later seam sends #enqueue to (Active Job wiring / test stub) asConfig *activestorage.Config // ActiveStorage process config (require "active_storage"); nil until first use, then a deterministic in-process config (MemStore + DiskService temp dir) cACChannelBase *RClass // ActionCable::Channel::Base, the superclass a subscription's channel subclass extends, backed by go-ruby-actioncable acServer object.Value // memoized ActionCable.server singleton (an ActionCable::Server over an in-process async adapter)