spine-framework 0.4.0 → 0.4.2

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.
package/.env.example CHANGED
@@ -69,8 +69,11 @@ APP_BASE_URL=http://localhost:8888
69
69
  # OPENAI_API_KEY=sk-...
70
70
 
71
71
  # ── Email (optional) ────────────────────────────────────────────
72
- # Provider: 'resend', 'sendgrid', or 'webhook' (default)
73
- # EMAIL_PROVIDER=resend
74
- # EMAIL_API_KEY=re_...
72
+ # Resend API key for the 'transactional-email' integration (invites and
73
+ # ticket-reply notifications). Until this is set, invites fall back to
74
+ # copyable links and email notification stages skip silently.
75
+ # RESEND_API_KEY=re_...
76
+ # Sender identity for outbound email (e.g. 'Answer Once <noreply@yourdomain.com>').
77
+ # Requires the domain to be verified in Resend; falls back to onboarding@resend.dev.
75
78
  # EMAIL_FROM=noreply@yourdomain.com
76
79
  # EMAIL_WEBHOOK_URL=https://hook.us1.make.com/your-scenario-id
@@ -1377,24 +1377,28 @@ async function validatePipelineSeedStages(
1377
1377
  )
1378
1378
  }
1379
1379
 
1380
- if (stageType !== 'send_notification') continue
1380
+ if (stageType !== 'send_notification' && stageType !== 'send_email') continue
1381
1381
  const recipients = stage.config?.recipients
1382
1382
  if (!Array.isArray(recipients)) {
1383
- throw new Error(`pipelines "${label}" (${file}) stage ${i} (send_notification): config.recipients must be an array`)
1383
+ throw new Error(`pipelines "${label}" (${file}) stage ${i} (${stageType}): config.recipients must be an array`)
1384
1384
  }
1385
1385
  for (const recipient of recipients) {
1386
1386
  const roleMatch = typeof recipient === 'string' ? recipient.match(NOTIFICATION_ROLE_RE) : null
1387
1387
  if (roleMatch) {
1388
1388
  roleSlugs ??= await getRoleSlugs()
1389
1389
  if (!roleSlugs.has(roleMatch[1])) {
1390
- throw new Error(`pipelines "${label}" (${file}) stage ${i} (send_notification): recipient "${recipient}" references a role that does not exist`)
1390
+ throw new Error(`pipelines "${label}" (${file}) stage ${i} (${stageType}): recipient "${recipient}" references a role that does not exist`)
1391
1391
  }
1392
1392
  continue
1393
1393
  }
1394
- if (recipient === 'self' || (typeof recipient === 'string' && NOTIFICATION_UUID_RE.test(recipient))) continue
1394
+ if (
1395
+ recipient === 'self' || recipient === 'owner' || recipient === 'watchers' ||
1396
+ (typeof recipient === 'string' && NOTIFICATION_UUID_RE.test(recipient)) ||
1397
+ (stageType === 'send_email' && typeof recipient === 'string' && recipient.includes('@'))
1398
+ ) continue
1395
1399
  throw new Error(
1396
- `pipelines "${label}" (${file}) stage ${i} (send_notification): invalid recipient ${JSON.stringify(recipient)} — ` +
1397
- `expected 'role:<slug>', 'self', or a person UUID`
1400
+ `pipelines "${label}" (${file}) stage ${i} (${stageType}): invalid recipient ${JSON.stringify(recipient)} — ` +
1401
+ `expected 'role:<slug>', 'self', 'owner', 'watchers', a person UUID${stageType === 'send_email' ? ', or an email address' : ''}`
1398
1402
  )
1399
1403
  }
1400
1404
  }
@@ -4256,7 +4256,23 @@ export async function runAgent(
4256
4256
  const threadVisibility = thread?.visibility || 'external'
4257
4257
  let state = getResearchState(thread)
4258
4258
  if (userMessage?.trim()) {
4259
- await saveMessage(threadId, userMessage.trim(), 'human', null, ctx, replyTypeSlug, threadVisibility)
4259
+ // Idempotent insert: the caller (ticket creation, sendMessage) often
4260
+ // persists the inbound row itself. When the thread's latest inbound
4261
+ // already carries this text the row exists — skip the insert so a
4262
+ // re-fired kick/step can never double-post the question. New-turn
4263
+ // semantics below (episode reset, user_question) are unaffected.
4264
+ const { data: lastInboundRows } = await adminDb
4265
+ .from('messages')
4266
+ .select('content')
4267
+ .eq('thread_id', threadId)
4268
+ .eq('direction', 'inbound')
4269
+ .order('sequence', { ascending: false })
4270
+ .order('created_at', { ascending: false })
4271
+ .limit(1)
4272
+ const lastInbound = Array.isArray(lastInboundRows) ? lastInboundRows[0] : lastInboundRows
4273
+ if ((lastInbound?.content || '').trim() !== userMessage.trim()) {
4274
+ await saveMessage(threadId, userMessage.trim(), 'human', null, ctx, replyTypeSlug, threadVisibility)
4275
+ }
4260
4276
  if (!state.user_question) state.user_question = userMessage.trim()
4261
4277
  }
4262
4278
  if (!userMessage?.trim() && !state.user_question) {
@@ -644,6 +644,64 @@ async function resolveAccountId(ctx: CoreContext, slug: string): Promise<string>
644
644
  // CIL[DOC-MICRO logic_summary="Template-bearing id fields resolve against config._triggerData then dynamic-import their engine — kb_ingest/kb_revoke import _shared/kb (canonical typed path with document lease); generate_embeddings/revoke_embeddings import functions/embeddings (legacy engine) and remain for cortex pipelines." notable_decisions="kb_ingest accepts item_id or document_id; kb_revoke accepts document_id or item_id — matching the seeded demo pipeline configs"]
645
645
  // CIL[DOC-CONFIDENCE pass="first" score="0.8" flags="none"]
646
646
  // CIL[DOC-STABILITY stability="stable"]
647
+ // Entity-scoped recipient handles understood by send_notification/send_email.
648
+ // 'owner' = created_by of the record the trigger fired on (for a message, the
649
+ // record its thread points at); 'watchers' = watchers rows on that record;
650
+ // 'self' = the person who created the triggering record.
651
+ const ENTITY_RECIPIENTS = new Set(['owner', 'watchers', 'self'])
652
+
653
+ // Walks message -> thread -> target so entity handles resolve against the
654
+ // owning record (e.g. the ticket item behind a reply), not just the event row.
655
+ async function resolveEntityTarget(entity: any): Promise<{ targetTable: string | null, targetId: string | null }> {
656
+ if (!entity?.type || !entity?.id) return { targetTable: null, targetId: null }
657
+ if (entity.type === 'messages' && entity?.data?.thread_id) {
658
+ const { data: th } = await adminDb
659
+ .from('threads')
660
+ .select('target_type, target_id')
661
+ .eq('id', entity.data.thread_id)
662
+ .maybeSingle()
663
+ if (th?.target_id) {
664
+ return { targetTable: th.target_type || 'items', targetId: th.target_id }
665
+ }
666
+ return { targetTable: 'threads', targetId: entity.data.thread_id }
667
+ }
668
+ return { targetTable: entity.type, targetId: entity.id }
669
+ }
670
+
671
+ async function resolveEntityRecipients(recipients: any[], entity: any): Promise<string[]> {
672
+ const wanted = (recipients || []).filter((r: any) => ENTITY_RECIPIENTS.has(r))
673
+ if (!wanted.length) return []
674
+
675
+ const ids: string[] = []
676
+ if (wanted.includes('self') && entity?.data?.created_by) {
677
+ ids.push(entity.data.created_by)
678
+ }
679
+
680
+ if (wanted.includes('owner') || wanted.includes('watchers')) {
681
+ const { targetTable, targetId } = await resolveEntityTarget(entity)
682
+ if (targetTable && targetId) {
683
+ if (wanted.includes('owner')) {
684
+ const { data: rec } = await adminDb
685
+ .from(targetTable)
686
+ .select('created_by')
687
+ .eq('id', targetId)
688
+ .maybeSingle()
689
+ if (rec?.created_by) ids.push(rec.created_by)
690
+ }
691
+ if (wanted.includes('watchers')) {
692
+ const { data: ws } = await adminDb
693
+ .from('watchers')
694
+ .select('person_id')
695
+ .eq('target_id', targetId)
696
+ for (const w of ws || []) {
697
+ if (w?.person_id) ids.push(w.person_id)
698
+ }
699
+ }
700
+ }
701
+ }
702
+ return [...new Set(ids)]
703
+ }
704
+
647
705
  const stageHandlers: Record<string, Function> = {
648
706
  'update_item': async (ctx: CoreContext, config: any) => {
649
707
  const triggerData = config._triggerData || {}
@@ -764,14 +822,46 @@ const stageHandlers: Record<string, Function> = {
764
822
  },
765
823
 
766
824
  'send_notification': async (ctx: CoreContext, config: any) => {
767
- const { message, entity_type, entity_id, recipients = [] } = config
825
+ const triggerData = config._triggerData
826
+ const recipients = resolveTemplates(config.recipients ?? [], triggerData)
827
+ const entity = triggerData?.entity
828
+
829
+ // Entity-scoped handles ('owner', 'watchers', 'self') resolve through the
830
+ // record the trigger fired on; role:/UUID entries go through notify.ts.
831
+ const entityPersonIds = await resolveEntityRecipients(recipients, entity)
832
+ const personIds = [
833
+ ...entityPersonIds,
834
+ ...(await resolveNotificationRecipients(ctx, recipients.filter((r: any) => typeof r === 'string' && !ENTITY_RECIPIENTS.has(r))))
835
+ ]
836
+ const message = resolveTemplates(config.message, triggerData)
837
+ const entity_type = config.entity_type
838
+ // entity_id 'target' resolves the record behind the event (e.g. the ticket
839
+ // a reply was posted on) so notifications deep-link to it.
840
+ let entity_id = resolveTemplates(config.entity_id, triggerData)
841
+ if (entity_id === 'target') {
842
+ entity_id = (await resolveEntityTarget(entity)).targetId || null
843
+ }
768
844
 
769
845
  // watchers is the subscription table (who follows what) — deliveries go to
770
846
  // the notifications table, one row per recipient. Migration 050 adds it.
771
- // CIL[DOC-NOTE type="behavior" content="Recipients may be person UUIDs or 'role:<slug>' handles — resolveNotificationRecipients maps role slugs to this account's active people ids; a role matching nobody is a valid 0-notification result, not an error."]
772
- const personIds = await resolveNotificationRecipients(ctx, recipients)
773
- const notifications = personIds.map((recipientId: string) => ({
774
- account_id: ctx.accountId,
847
+ // Recipient account scoping: entity handles (owner/watchers/self) can point
848
+ // at people outside ctx.accountId — e.g. a staff reply in the support
849
+ // account notifying a customer — so each row is stamped with the
850
+ // recipient's own account_id, falling back to ctx.accountId.
851
+ // CIL[DOC-NOTE type="behavior" content="Recipients may be person UUIDs, 'role:<slug>' handles, or entity handles 'owner'/'watchers'/'self' — resolveNotificationRecipients maps role slugs to this account's active people ids; a role matching nobody is a valid 0-notification result, not an error."]
852
+ const uniquePersonIds = [...new Set(personIds)]
853
+ const personAccounts = new Map<string, string>()
854
+ if (uniquePersonIds.length) {
855
+ const { data: peopleRows } = await adminDb
856
+ .from('people')
857
+ .select('id, account_id')
858
+ .in('id', uniquePersonIds)
859
+ for (const p of peopleRows || []) {
860
+ if (p?.id && p?.account_id) personAccounts.set(p.id, p.account_id)
861
+ }
862
+ }
863
+ const notifications = uniquePersonIds.map((recipientId: string) => ({
864
+ account_id: personAccounts.get(recipientId) || ctx.accountId,
775
865
  person_id: recipientId,
776
866
  message,
777
867
  source_type: 'pipeline_execution',
@@ -792,6 +882,96 @@ const stageHandlers: Record<string, Function> = {
792
882
  return { success: true, notified_count: notifications.length }
793
883
  },
794
884
 
885
+ // Sends email through an outbound integration (e.g. Resend) to resolved
886
+ // recipients. Recipients accept literal emails plus the same handles as
887
+ // send_notification (role:<slug>, person UUID, 'owner', 'watchers', 'self').
888
+ // 'exclude_actor' (default true) drops the person who wrote the triggering
889
+ // record so a support rep never emails themselves. A missing/inactive
890
+ // integration is reported as success with emailed_count 0 — pipelines stay
891
+ // green until the customer configures a provider.
892
+ 'send_email': async (ctx: CoreContext, config: any) => {
893
+ const triggerData = config._triggerData
894
+ const entity = triggerData?.entity
895
+ const integrationSlug = config.integration_slug || 'transactional-email'
896
+
897
+ const { data: integration } = await adminDb
898
+ .from('integrations')
899
+ .select('id, config')
900
+ .eq('slug', integrationSlug)
901
+ .eq('is_active', true)
902
+ .limit(1)
903
+ .maybeSingle()
904
+
905
+ // No configured provider → no-op so seeded pipelines stay green on installs
906
+ // that never set up email.
907
+ if (!integration) {
908
+ return { success: true, emailed_count: 0, skipped: 'integration not configured' }
909
+ }
910
+
911
+ // Named templates live on the integration (integration.config.templates.<key>
912
+ // = {subject, html, text}); inline stage config supplies them otherwise.
913
+ let { subject, html, text } = config
914
+ const named = config.template && integration?.config?.templates?.[config.template]
915
+ if (named) {
916
+ subject = named.subject ?? subject
917
+ html = named.html ?? html
918
+ text = named.text ?? text
919
+ }
920
+ subject = resolveTemplates(subject, triggerData)
921
+ html = resolveTemplates(html, triggerData)
922
+ text = resolveTemplates(text, triggerData)
923
+
924
+ if (!subject || (!html && !text)) {
925
+ throw new Error('send_email: subject and html or text are required (inline or via integration template)')
926
+ }
927
+
928
+ const recipients = resolveTemplates(config.recipients ?? [], triggerData)
929
+ const personIds = new Set([
930
+ ...(await resolveEntityRecipients(recipients, entity)),
931
+ ...(await resolveNotificationRecipients(ctx, recipients.filter((r: any) => typeof r === 'string' && !ENTITY_RECIPIENTS.has(r) && !r.includes('@')))),
932
+ ])
933
+ if (config.exclude_actor !== false && entity?.data?.created_by) {
934
+ personIds.delete(entity.data.created_by)
935
+ }
936
+
937
+ const emails = new Set(recipients.filter((r: any) => typeof r === 'string' && r.includes('@')))
938
+ if (personIds.size) {
939
+ const { data: people } = await adminDb
940
+ .from('people')
941
+ .select('id, email')
942
+ .in('id', [...personIds])
943
+ for (const p of people || []) {
944
+ if (p?.email) emails.add(p.email)
945
+ }
946
+ }
947
+
948
+ if (!emails.size) return { success: true, emailed_count: 0 }
949
+
950
+ try {
951
+ const result = await executeOutbound(ctx, {
952
+ integration_slug: integrationSlug,
953
+ action_config: {
954
+ action_slug: 'send_email',
955
+ to: [...emails],
956
+ subject,
957
+ html,
958
+ text,
959
+ ...(config.from ? { from: config.from } : {}),
960
+ ...(config.reply_to ? { reply_to: config.reply_to } : {}),
961
+ },
962
+ })
963
+ return { success: !!result?.success, emailed_count: emails.size }
964
+ } catch (err: any) {
965
+ // Credential/env problems mean the provider was never configured — treat
966
+ // like "not configured" rather than failing the pipeline.
967
+ if (/credential|Environment variable|api_token/i.test(err?.message || '')) {
968
+ console.warn(`[send_email] skipped — ${err.message}`)
969
+ return { success: true, emailed_count: 0, skipped: err.message }
970
+ }
971
+ throw err
972
+ }
973
+ },
974
+
795
975
  'run_pipeline': async (ctx: CoreContext, config: any) => {
796
976
  const { pipeline_id, trigger_data = {} } = config
797
977
 
@@ -0,0 +1,44 @@
1
+ // Resend outbound adapter — maps a composed email in actionConfig to the
2
+ // Resend /emails API. `from` resolves actionConfig.from -> integration.config.from
3
+ // -> EMAIL_FROM env -> Resend's shared onboarding sender, so a fresh install
4
+ // delivers test mail before the customer verifies their own domain.
5
+
6
+ export function buildRequest(_record: any, ctx: any) {
7
+ const apiKey = ctx?.apiKey?.value
8
+ const a = ctx?.actionConfig || {}
9
+ const to = Array.isArray(a.to) ? a.to : a.to ? [a.to] : []
10
+
11
+ if (!to.length) {
12
+ throw new Error('resend-outbound adapter requires actionConfig.to')
13
+ }
14
+ if (!a.subject) {
15
+ throw new Error('resend-outbound adapter requires actionConfig.subject')
16
+ }
17
+ if (!apiKey) {
18
+ throw new Error('resend-outbound adapter requires api_token credential')
19
+ }
20
+
21
+ const from =
22
+ a.from ||
23
+ ctx?.integration?.config?.from ||
24
+ process.env.EMAIL_FROM ||
25
+ 'onboarding@resend.dev'
26
+
27
+ const body: Record<string, any> = {
28
+ from,
29
+ to,
30
+ subject: a.subject,
31
+ ...(a.html ? { html: a.html } : { text: a.text || '' }),
32
+ }
33
+ if (a.reply_to) body.reply_to = a.reply_to
34
+
35
+ return {
36
+ url: ctx?.integration?.config?.api_url || 'https://api.resend.com/emails',
37
+ method: 'POST',
38
+ headers: {
39
+ 'Content-Type': 'application/json',
40
+ Authorization: `Bearer ${apiKey}`,
41
+ },
42
+ body,
43
+ }
44
+ }
@@ -15,6 +15,7 @@ import { resolveCredential } from './_shared/resolve-credentials'
15
15
  import { get as adminDataGet, update as adminDataUpdate } from './admin-data'
16
16
  import * as openaiOutbound from './adapters/openai-outbound'
17
17
  import * as genericWebhookOutbound from './adapters/generic-webhook-outbound'
18
+ import * as resendOutbound from './adapters/resend-outbound'
18
19
  // CIL[DOC-VECTOR type="context" content="purpose='Module header: imports and adapter registration for outbound integration delivery'; No verified external callers; imports: .framework/functions/_shared/middleware.ts,.framework/functions/_shared/db.ts,.framework/functions/_shared/audit.ts"]
19
20
  // CIL[DOC-VECTOR type="blast_radius" content="No direct caller data from CIL graph; local impact only"]
20
21
  // CIL[DOC-VECTOR type="configuration" content="No environment or build configuration dependencies identified"]
@@ -368,7 +369,8 @@ export async function execute(ctx: CoreContext, body: any = {}): Promise<any> {
368
369
  // CIL[DOC-STABILITY stability="stable"]
369
370
  const builtInOutboundAdapters: Record<string, any> = {
370
371
  'adapters/openai-outbound': openaiOutbound,
371
- 'adapters/generic-webhook-outbound': genericWebhookOutbound
372
+ 'adapters/generic-webhook-outbound': genericWebhookOutbound,
373
+ 'adapters/resend-outbound': resendOutbound
372
374
  }
373
375
  // CIL[DOC-VECTOR type="context" content="purpose='Registry of built-in outbound adapter modules'; No verified external callers; imports: .framework/functions/_shared/middleware.ts,.framework/functions/_shared/db.ts,.framework/functions/_shared/audit.ts"]
374
376
  // CIL[DOC-VECTOR type="blast_radius" content="No direct caller data from CIL graph; local impact only"]
@@ -821,17 +821,21 @@ export const createInvite = createHandler(async (ctx, body) => {
821
821
  const appBaseUrl = resolveInviteBaseUrl(base_url)
822
822
  const inviteUrl = `${appBaseUrl}/register?t=${token}`
823
823
 
824
- // Email delivery: when an 'invite-email' integration is configured, send the
825
- // invite through the outbound broker. Always return invite_url so the UI can
826
- // fall back to a copyable link when no provider is configured.
824
+ // Email delivery: when an 'invite-email' integration (or the shared
825
+ // 'transactional-email' one) is configured, send the invite through the
826
+ // outbound broker. Always return invite_url so the UI can fall back to a
827
+ // copyable link when no provider is configured.
827
828
  let emailSent = false
828
829
  try {
829
- const { data: emailIntegration } = await adminDb
830
+ const { data: emailIntegrations } = await adminDb
830
831
  .from('integrations')
831
- .select('id')
832
- .eq('slug', 'invite-email')
832
+ .select('id, slug, config')
833
+ .in('slug', ['invite-email', 'transactional-email'])
833
834
  .eq('is_active', true)
834
- .maybeSingle()
835
+
836
+ const emailIntegration =
837
+ (emailIntegrations || []).find((i: any) => i.slug === 'invite-email') ||
838
+ (emailIntegrations || [])[0]
835
839
 
836
840
  if (emailIntegration) {
837
841
  const { data: account } = await adminDb
@@ -839,14 +843,36 @@ export const createInvite = createHandler(async (ctx, body) => {
839
843
  .select('display_name')
840
844
  .eq('id', account_id)
841
845
  .maybeSingle()
846
+ const accountName = account?.display_name || ''
847
+
848
+ // Email body: integration.config.templates.invite overrides the default.
849
+ // Template placeholders are {{to_name}}, {{invite_url}}, {{account_name}},
850
+ // {{expires_at}}.
851
+ const tpl = emailIntegration.config?.templates?.invite || {}
852
+ const render = (s: string) =>
853
+ (s || '')
854
+ .replace(/\{\{\s*to_name\s*\}\}/g, stubName || '')
855
+ .replace(/\{\{\s*invite_url\s*\}\}/g, inviteUrl)
856
+ .replace(/\{\{\s*account_name\s*\}\}/g, accountName)
857
+ .replace(/\{\{\s*expires_at\s*\}\}/g, expiresAt || '')
858
+ const subject = render(tpl.subject) ||
859
+ `You're invited to join ${accountName || 'your team'}`
860
+ const html = render(tpl.html) ||
861
+ `<p>Hi${stubName ? ' ' + stubName : ''},</p>` +
862
+ `<p>You've been invited to join <strong>${accountName || 'the team'}</strong>.</p>` +
863
+ `<p><a href="${inviteUrl}">Accept your invitation</a></p>` +
864
+ `<p>This link expires ${expiresAt || 'soon'}. If the button doesn't work, paste this into your browser:<br>${inviteUrl}</p>`
865
+
842
866
  await checkRateLimit(ctx, 'invite_email', { identifier: `acct:${account_id}` })
843
867
  const result = await executeOutbound(ctx, {
844
- integration_slug: 'invite-email',
868
+ integration_slug: emailIntegration.slug,
845
869
  action_config: {
846
870
  to: email,
847
- to_name: stubName,
871
+ subject,
872
+ html,
848
873
  invite_url: inviteUrl,
849
- account_name: account?.display_name || '',
874
+ to_name: stubName,
875
+ account_name: accountName,
850
876
  invited_by: ctx.principal.id,
851
877
  expires_at: expiresAt,
852
878
  }
@@ -0,0 +1,9 @@
1
+ -- Seeds the send_email pipeline action. send_email resolves recipients
2
+ -- (literal emails, person UUIDs, 'role:<slug>', or entity handles
3
+ -- 'owner'/'watchers'/'self') and sends through an outbound integration
4
+ -- (default slug 'transactional-email', e.g. Resend). No-op when the
5
+ -- integration is not configured.
6
+
7
+ INSERT INTO public.actions (slug, name, description, handler, handler_module, ownership, is_system, is_active)
8
+ SELECT 'send_email', 'Send Email', 'Core pipeline stage: send email to resolved recipients via an outbound email integration.', 'send_email', 'functions', 'platform', true, true
9
+ WHERE NOT EXISTS (SELECT 1 FROM public.actions WHERE slug = 'send_email');
@@ -16,7 +16,8 @@ vi.mock('../../functions/_shared/db.ts', () => ({
16
16
 
17
17
  vi.mock('../../functions/_shared/audit.ts', () => ({
18
18
  emitAudit: vi.fn().mockResolvedValue(undefined),
19
- emitSpan: vi.fn().mockResolvedValue(undefined)
19
+ emitSpan: vi.fn().mockResolvedValue(undefined),
20
+ emitLog: vi.fn().mockResolvedValue(undefined)
20
21
  }))
21
22
 
22
23
  vi.mock('../../functions/embeddings.ts', () => ({
@@ -610,7 +611,7 @@ describe('send_notification — writes to notifications, not watchers', () => {
610
611
  insert: vi.fn(() => Promise.resolve({ data: null, error: { message: 'watchers must not receive notification payloads' } }))
611
612
  } as any
612
613
  }
613
- return { select: vi.fn().mockReturnThis(), eq: vi.fn().mockReturnThis(), insert: vi.fn().mockReturnThis(), update: vi.fn().mockReturnThis(), single: vi.fn().mockResolvedValue({ data: null, error: null }) } as any
614
+ return { select: vi.fn().mockReturnThis(), eq: vi.fn().mockReturnThis(), in: vi.fn().mockReturnThis(), is: vi.fn().mockReturnThis(), not: vi.fn().mockReturnThis(), limit: vi.fn().mockReturnThis(), order: vi.fn().mockReturnThis(), maybeSingle: vi.fn().mockResolvedValue({ data: null, error: null }), insert: vi.fn().mockReturnThis(), update: vi.fn().mockReturnThis(), single: vi.fn().mockResolvedValue({ data: null, error: null }) } as any
614
615
  })
615
616
  }
616
617
 
@@ -666,6 +667,10 @@ describe('send_notification — role:<slug> recipient resolution', () => {
666
667
  const peopleQ: any = {
667
668
  select: vi.fn(() => peopleQ),
668
669
  eq: vi.fn(() => peopleQ),
670
+ in: vi.fn(() => peopleQ),
671
+ is: vi.fn(() => peopleQ),
672
+ limit: vi.fn(() => peopleQ),
673
+ maybeSingle: vi.fn(() => Promise.resolve({ data: null, error: null })),
669
674
  then: (onF: any, onR: any) =>
670
675
  Promise.resolve({ data: peopleRows, error: peopleError ?? null }).then(onF, onR)
671
676
  }
@@ -708,7 +713,7 @@ describe('send_notification — role:<slug> recipient resolution', () => {
708
713
  })
709
714
  } as any
710
715
  }
711
- return { select: vi.fn().mockReturnThis(), eq: vi.fn().mockReturnThis(), insert: vi.fn().mockReturnThis(), update: vi.fn().mockReturnThis(), single: vi.fn().mockResolvedValue({ data: null, error: null }) } as any
716
+ return { select: vi.fn().mockReturnThis(), eq: vi.fn().mockReturnThis(), in: vi.fn().mockReturnThis(), is: vi.fn().mockReturnThis(), not: vi.fn().mockReturnThis(), limit: vi.fn().mockReturnThis(), order: vi.fn().mockReturnThis(), maybeSingle: vi.fn().mockResolvedValue({ data: null, error: null }), insert: vi.fn().mockReturnThis(), update: vi.fn().mockReturnThis(), single: vi.fn().mockResolvedValue({ data: null, error: null }) } as any
712
717
  })
713
718
  }
714
719
 
@@ -752,4 +757,166 @@ describe('send_notification — role:<slug> recipient resolution', () => {
752
757
  }
753
758
  })
754
759
  })
755
- // CIL[END stable_id="~/.framework/tests/unit/pipeline-runner.test.ts:describe_sendNotification_roleRecipients:block"]
760
+ // CIL[END stable_id="~/.framework/tests/unit/pipeline-runner.test.ts:describe_sendNotification_roleRecipients:block"]
761
+ // send_email — resolves entity recipients through message->thread->target,
762
+ // composes from integration templates or inline config, and skips cleanly when
763
+ // no provider is configured.
764
+ describe('send_email — recipient resolution + outbound send', () => {
765
+ beforeEach(() => {
766
+ vi.clearAllMocks()
767
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
768
+ })
769
+
770
+ const OWNER_ID = '33333333-3333-4333-8333-333333333333'
771
+ const ACTOR_ID = '44444444-4444-4444-8444-444444444444'
772
+ const triggerData = {
773
+ entity: {
774
+ type: 'messages',
775
+ id: 'm-1',
776
+ data: { thread_id: 'th-1', created_by: ACTOR_ID, content: 'reply body', direction: 'outbound', visibility: 'external' },
777
+ },
778
+ }
779
+
780
+ function mockEmailPipeline(stageConfig: any, integrationRow: any) {
781
+ const chain: any = {
782
+ select: vi.fn(() => chain),
783
+ eq: vi.fn(() => chain),
784
+ in: vi.fn(() => chain),
785
+ is: vi.fn(() => chain),
786
+ not: vi.fn(() => chain),
787
+ limit: vi.fn(() => chain),
788
+ order: vi.fn(() => chain),
789
+ insert: vi.fn(() => chain),
790
+ update: vi.fn(() => chain),
791
+ single: vi.fn(() => Promise.resolve({ data: null, error: null })),
792
+ maybeSingle: vi.fn(() => Promise.resolve({ data: null, error: null })),
793
+ then: (onF: any, onR: any) => Promise.resolve({ data: null, error: null }).then(onF, onR),
794
+ }
795
+ vi.mocked(adminDb.from).mockImplementation((table: string) => {
796
+ if (table === 'pipelines') {
797
+ return {
798
+ select: vi.fn().mockReturnThis(),
799
+ eq: vi.fn().mockReturnThis(),
800
+ single: vi.fn().mockResolvedValue({
801
+ data: { id: 'pipe-1', stages: [{ stage_type: 'send_email', config: stageConfig }], is_active: true },
802
+ error: null,
803
+ }),
804
+ } as any
805
+ }
806
+ if (table === 'pipeline_executions') {
807
+ return {
808
+ insert: vi.fn().mockReturnThis(),
809
+ update: vi.fn().mockReturnThis(),
810
+ select: vi.fn().mockReturnThis(),
811
+ eq: vi.fn().mockResolvedValue({ data: null, error: null }),
812
+ single: vi.fn().mockResolvedValue({ data: { id: EXEC_ID }, error: null }),
813
+ } as any
814
+ }
815
+ if (table === 'actions') {
816
+ return {
817
+ select: vi.fn().mockReturnThis(),
818
+ eq: vi.fn().mockReturnThis(),
819
+ single: vi.fn().mockResolvedValue({
820
+ data: { id: 'a-2', handler: 'send_email', handler_module: 'functions', config: {} },
821
+ error: null,
822
+ }),
823
+ } as any
824
+ }
825
+ if (table === 'integrations') {
826
+ const q: any = {
827
+ select: vi.fn(() => q), eq: vi.fn(() => q), in: vi.fn(() => q), is: vi.fn(() => q),
828
+ limit: vi.fn(() => q),
829
+ single: vi.fn(() => Promise.resolve({ data: integrationRow, error: null })),
830
+ maybeSingle: vi.fn(() => Promise.resolve({ data: integrationRow, error: null })),
831
+ }
832
+ return q
833
+ }
834
+ if (table === 'threads') {
835
+ const q: any = {
836
+ select: vi.fn(() => q), eq: vi.fn(() => q),
837
+ maybeSingle: vi.fn(() => Promise.resolve({ data: { target_type: 'items', target_id: 'item-1' }, error: null })),
838
+ }
839
+ return q
840
+ }
841
+ if (table === 'items') {
842
+ const q: any = {
843
+ select: vi.fn(() => q), eq: vi.fn(() => q),
844
+ maybeSingle: vi.fn(() => Promise.resolve({ data: { created_by: OWNER_ID }, error: null })),
845
+ }
846
+ return q
847
+ }
848
+ if (table === 'people') {
849
+ const ALL_PEOPLE = [
850
+ { id: OWNER_ID, email: 'owner@x.test', account_id: 'acct-cust' },
851
+ { id: ACTOR_ID, email: 'staff@x.test', account_id: 'acct-staff' },
852
+ ]
853
+ let idsFilter: string[] | null = null
854
+ const q: any = {
855
+ select: vi.fn(() => q), eq: vi.fn(() => q),
856
+ in: vi.fn((_col: string, ids: string[]) => { idsFilter = ids; return q }),
857
+ then: (onF: any, onR: any) =>
858
+ Promise.resolve({ data: ALL_PEOPLE.filter(p => !idsFilter || idsFilter.includes(p.id)), error: null }).then(onF, onR),
859
+ }
860
+ return q
861
+ }
862
+ if (table === 'webhook_deliveries') {
863
+ const q: any = {
864
+ select: vi.fn(() => q), eq: vi.fn(() => q), in: vi.fn(() => q),
865
+ insert: vi.fn(() => q), update: vi.fn(() => q),
866
+ single: vi.fn(() => Promise.resolve({ data: { id: 'del-1' }, error: null })),
867
+ maybeSingle: vi.fn(() => Promise.resolve({ data: null, error: null })),
868
+ then: (onF: any, onR: any) => Promise.resolve({ data: null, error: null }).then(onF, onR),
869
+ }
870
+ return q
871
+ }
872
+ return chain
873
+ })
874
+ }
875
+
876
+ const resendIntegration = {
877
+ id: 'int-1',
878
+ slug: 'transactional-email',
879
+ config: {
880
+ outbound_adapter: 'adapters/resend-outbound',
881
+ api_url: 'https://api.resend.com/emails',
882
+ from: 'Support <noreply@x.test>',
883
+ templates: { ticket_reply: { subject: 'Re: ticket', text: 'staff said: {{record.content}}' } },
884
+ },
885
+ credentials: { api_token: { source: 'value', value: 're_test' } },
886
+ }
887
+
888
+ it("emails the ticket owner via Resend and excludes the actor", async () => {
889
+ mockEmailPipeline(
890
+ { integration_slug: 'transactional-email', template: 'ticket_reply', recipients: ['owner'], exclude_actor: true },
891
+ resendIntegration
892
+ )
893
+ const fetchSpy = vi.fn().mockResolvedValue(new Response('{"id":"em_1"}', { status: 200 }))
894
+ vi.stubGlobal('fetch', fetchSpy)
895
+
896
+ const result = await runPipeline('pipe-1', triggerData, makeCtx())
897
+ expect(result.stages[0].status).toBe('success')
898
+ expect(result.stages[0].output.emailed_count).toBe(1)
899
+
900
+ expect(fetchSpy).toHaveBeenCalledTimes(1)
901
+ const [url, init] = fetchSpy.mock.calls[0]
902
+ expect(url).toBe('https://api.resend.com/emails')
903
+ const body = JSON.parse(init.body)
904
+ expect(body.to).toEqual(['owner@x.test']) // owner resolved through message->thread->item
905
+ expect(body.to).not.toContain('staff@x.test') // actor excluded
906
+ expect(body.from).toBe('Support <noreply@x.test>')
907
+ expect(body.subject).toBe('Re: ticket')
908
+ expect(init.headers.Authorization).toBe('Bearer re_test')
909
+ vi.unstubAllGlobals()
910
+ })
911
+
912
+ it('no-ops with emailed_count 0 when the integration is not configured', async () => {
913
+ mockEmailPipeline(
914
+ { integration_slug: 'transactional-email', subject: 'x', text: 'y', recipients: ['owner'] },
915
+ null
916
+ )
917
+ const result = await runPipeline('pipe-1', triggerData, makeCtx())
918
+ expect(result.stages[0].status).toBe('success')
919
+ expect(result.stages[0].output.emailed_count).toBe(0)
920
+ expect(result.stages[0].output.skipped).toMatch(/not configured/)
921
+ })
922
+ })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-framework",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Multi-tenant, modular application platform for modern SaaS systems",
5
5
  "type": "module",
6
6
  "bin": {