Skip to content

feat(intent): send a document by e-mail - the notify block + attach: print (#6356) - #6435

Merged
delchev merged 5 commits into
masterfrom
feat/6356-send-document-by-email
Jul 28, 2026
Merged

feat(intent): send a document by e-mail - the notify block + attach: print (#6356)#6435
delchev merged 5 commits into
masterfrom
feat/6356-send-document-by-email

Conversation

@delchev

@delchev delchev commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

What

Closes #6356. An intent could already render any document to PDF and send plain-text mail from a schedule, but not do the single most common outbound action a business document has: send the document itself - the invoice to its customer, the payslip to its employee, an escalating reminder that carries the invoice.

to / subject / body (+ channel) becomes one reusable notify block, authored at every place an intent can act on a record:

Call site Reads Sends when Generated as
notifications[] the event record create / update / delete <Name>Notification.java
schedules[].notify each matched row every cron tick, per row <Name>Job.java
transitions[].notify the transitioned record after the status flip commits inside <Name>Transition.java
a serviceTask's args.notify the process's trigger record the flow reaches that step <Process><Step>Send.java (new sends glue)
    notify:
      to: Customer.email                 # literal / field / one-hop relation.field (cross-model ok)
      subject: "Invoice {number}"        # {field} / {relation.field} interpolation
      body: "Dear {Customer.name}, please find invoice {number} attached."
      attach: print                      # render THIS record's .print to PDF and attach it
      language: bg                       # optional print-template language

attach: print reuses the generated <Entity>PrintFeeder + sdk.print.Print - the two steps the snapshot delegate already takes - so no module ever needs a hand-written listener around the print engine. The attachment is named after the document's number: field (INV0000042.pdf), else <Entity> <id>.pdf.

Failure semantics, per call site (deliberate)

  • A recipient that resolves to nothing is a logged no-op - a record with nobody to mail must not stall a flow.
  • A transitions[].notify can never fail its transition: the flip is the endpoint's contract and has already committed, so SMTP cannot turn a successful void into an error.
  • A sending serviceTask does fail on a delivery error, so the process engine retries - the message is that step's whole work.

Guardrails

  • Only a document (header + line-items child) can be attached; the parser rejects attach: print on anything else rather than mail a note claiming a document it cannot render. The check mirrors the print generator's own document-master resolution, so the parser can never allow what generation would silently drop.
  • A sending serviceTask stands alone: notify cannot share a step with setField / setRelationField / call / delegate.
  • ServiceTaskHandlerGenerator no longer scaffolds a custom/<Step>.java stub for a notify step (it would never be invoked).

Platform side

MailClient accepts a part's data as a raw byte[] in addition to the historical JSON int-array string, so a rendered PDF does not have to travel through a JSON array of numbers. Backward compatible; both shapes are unit-tested.

Test

  • GlueSendDocumentTest - the pre-rendered glue at all four call sites, the empty-key contract for a generate schedule, and both parser rejections (non-document attach, notify combined with setField).
  • MailClientAttachmentTest - a byte[] and a JSON-array attachment reach the MIME part byte-identically.
  • IntentEmissionCoverageIT (extended fixture) - emission: the PDF attachment part, Print.render("Bill","en", new BillPrintFeeder().feed(entity.Id)), the one-hop FK recipient load, the fail-soft catch, the BPMN binding of BillFlowMailBillSend, and no scaffolded custom/MailBill.java. Runtime: a notify-carrying transition returns 200 with the status written when the mail cannot be delivered, and a sending step logs its no-recipient no-op.
  • Full engine-intent suite + api-mail green; formatter clean.

Docs

intent-assistant-guide.md gains a send a document by e-mail section and reference-table rows. The vendor-neutral spec (intentfile.org) and the platform docs (dirigible.io) are updated in their own PRs, per the documentation-sync contract in engine-intent/CLAUDE.md.

🤖 Generated with Claude Code

…print (#6356)

An intent could render any document to PDF (print templates) and could send
plain-text mail from a schedule, but not do the single most common outbound
action a business document has: send the document itself - the invoice to its
customer, the payslip to its employee, a reminder carrying the invoice.

`to` / `subject` / `body` (+ `channel`) becomes ONE reusable notify block,
authored at every place an intent can act on a record:

  notifications[]            on the entity's create / update / delete
  schedules[].notify         per row matched by a cron query
  transitions[].notify       after a guarded status flip commits
  serviceTask args.notify    when a process flow reaches that step

`attach: print` renders the record's own .print template server-side - the
generated <Entity>PrintFeeder assembles the { document, items } payload and
sdk.print.Print renders it, the same two steps the snapshot delegate takes -
and attaches the PDF, named after the document's `number:` field.

Failure semantics differ per call site, on purpose: a recipient that resolves
to nothing is a logged no-op (a record with nobody to mail must not stall a
flow); a transition's mail can NEVER fail its transition (the flip is the
endpoint's contract and has already committed); a sending serviceTask DOES
fail so the process engine retries, because the message is that step's work.

Only a document (header + line-items child) can be attached - the parser
rejects `attach: print` on anything else rather than let a mail go out
claiming a document it cannot render. A sending serviceTask stands alone:
notify cannot share a step with setField / setRelationField / call / delegate.

MailClient now accepts a part's `data` as a raw byte[] in addition to the
historical JSON int-array string, so a rendered PDF does not have to travel
through a JSON array.

Emission + runtime coverage in IntentEmissionCoverageIT: the PDF attachment
part, the feeder-backed Print.render, the one-hop FK recipient load, the BPMN
binding of the generated sender, no scaffolded custom stub - and at runtime a
notify-carrying transition still returns 200 with the status written when the
mail cannot be delivered, plus the no-recipient no-op on a sending step.

Closes #6356

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
delchev and others added 2 commits July 28, 2026 15:53
…keyProperty

The smoke gate caught it: IntentEngineIT asserts that an opted-out trigger leaves
no trigger entry in the .glue, and it used "keyProperty" as the proxy for "this is
a trigger entry". Adding keyProperty to the notifications / schedules entries (the
PK an attach: print feeds the print feeder with) made that proxy match non-trigger
glue, so the test failed on a true statement about the new key rather than about
triggers.

The attachment's PK reference is now attachKeyProperty on notifications,
schedules and transitions, grouped with the other attach* keys. `sends` keeps
keyProperty, where it genuinely IS the step's process variable - the same meaning
setters and writers give it.

The IT's proxy now keys on "businessKeyProperty", which only a trigger emits, so
the next glue block that legitimately needs a PK reference cannot break it; the
comment records why.

Generated code is unchanged (same value, different glue key). Verified:
engine-intent unit tests green, IntentEngineIT#settings_overrides_skip_generation_
and_are_preserved green, IntentEmissionCoverageIT green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…of printing

Two things, both found in review of the send-document glue.

1. FAN-OUT. A notify block resolved exactly one recipient, so the two cases the
   suite actually needs were impossible to express: a payroll run that mails
   every payslip to its own employee, and a request for quotation that goes out
   to each invited supplier. `notify.forEach: <Entity>` now sends ONE message per
   row of a related entity - the rows whose to-one FK points back at the record -
   and every path resolves against the ROW: the recipient, the {placeholders},
   and the attached document (attach: print renders the ROW's own print).

   The row entity must have exactly ONE to-one relation back to the record; zero
   means the rows are unrelated, two or more make the intended set ambiguous, and
   both are parse-time errors rather than a quietly wrong set of recipients.

   Per-row fail-soft, always: a row with no address is skipped, a failed send is
   logged, and the step/transition completes with a summary. Failing the task
   would have the engine retry the WHOLE fan-out and mail everyone who already
   received their message a second time - a partial send is not idempotent.

2. LOGGING. The send templates shipped System.err.println / System.out.println.
   Generated code now takes the client SDK logger (sdk.log.Logging, nested under
   the platform's `app.` root) with SLF4J-style placeholders and the throwable
   passed last - so the output has a level, a logger name and a home in the Logs
   view, and can be turned down in production. The `custom/` stub
   ServiceTaskHandlerGenerator scaffolds does the same, because a generated class
   is read as house style by every application developer.

   The rule is now written down where it cannot leak again: "Never print to
   stdout or stderr - log" in the root CLAUDE.md conventions (with the single
   documented exception - ConsoleWebsocketHandler.distribute is called BY the
   appender, so logging there recurses), and the generated-code half in
   engine-intent/CLAUDE.md.

Verified: engine-intent 243 tests green, incl. four new fan-out cases (the
payroll shape end to end, the empty-keys contract without a fan-out, and both
parser rejections).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
delchev and others added 2 commits July 28, 2026 20:22
The custom/ service-task stub stopped printing and started logging through the
SDK logger in 69494fc, but IntentEngineIT still asserted the old printed text
("OrderApproval: notifyCustomer service task executed."), so smoke-tests went red
on the one line the commit had made obsolete. The engine-intent unit tests moved
with the template; this integration assertion did not.

It now pins what the stub actually emits - the named logger and the parameterised
default message, both resolved for the OrderApproval/notifyCustomer step - plus a
System.out/System.err guard, so the next time a generated class reaches for a
print the test says so instead of a string comparison quietly aging out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@delchev
delchev merged commit 4a5be72 into master Jul 28, 2026
10 checks passed
@delchev
delchev deleted the feat/6356-send-document-by-email branch July 28, 2026 19:54
delchev added a commit that referenced this pull request Jul 28, 2026
One conflict, in the module guide's `postings:` bullet, which both sides rewrote:
this branch documented the new onCreate trigger, master (#6435) reworded the same
sentence's spike-findings reference. Resolution keeps both meanings - the onCreate
documentation, and the NEUTRAL wording for the reference: master's rewording named a
downstream product, which does not belong in this repository's content.

Everything else auto-merged. Verified rather than assumed, since both sides had edited
IntentParser, GlueIntentGenerator, generateUtils.js and IntentEmissionCoverageIT: the
engine-intent suite is green at 252 tests, including BOTH sides' new cases
(GluePostingsTest from this branch, GlueSendDocumentTest from master), and
tests-integrations compiles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
delchev added a commit that referenced this pull request Jul 29, 2026
…ch) (#6445)

The notify fan-out shipped with #6435 - NotificationIntent.forEach, NotifySupport
.fanOut, the glue keys, Send.java.template's per-row loop and thirteen unit cases are
all on master - but the authoring guide never got its section: no "fan-out", no
forEach row in the keyword table. The only forEach the guide describes is the
unrelated generates/children collection (`forEach: { entity: … }`), so an assistant
reading the guide cannot author the keyword the engine accepts, and would plausibly
try the map shape, which a notify block does not take.

The text was written when the feature was built and never left a working tree. It
documents the shape (`forEach: <Entity>`, every path then resolving against the ROW -
recipient, {placeholders} and the row's own attach: print), the exactly-one-back-
relation rule, and why a fan-out is fail-soft per row rather than failing the step: a
retry would re-mail everyone already served, so a partial send cannot be idempotent.

Each claim was checked against master rather than trusted: the plain-string forEach in
NotificationIntent, the exactly-ONE rule in IntentParser.validateNotifyBlock (unknown
entity and zero-or-ambiguous back-reference both reported), the sent / no-recipient /
failed counters and the summary log in Send.java.template, and attach: print
re-pointing at the row entity.

Docs only - no code, no behaviour change.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Intent: declarative send-document-by-e-mail glue (process step / transition / schedule attach)

1 participant