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 +6 -3
- package/.framework/cli/commands/install-app.ts +10 -6
- package/.framework/functions/_shared/agent-runner.ts +17 -1
- package/.framework/functions/_shared/pipeline-runner.ts +185 -5
- package/.framework/functions/adapters/resend-outbound.ts +44 -0
- package/.framework/functions/integration-outbound.ts +3 -1
- package/.framework/functions/invites.ts +36 -10
- package/.framework/migrations/062_send_email_action.sql +9 -0
- package/.framework/tests/unit/pipeline-runner.test.ts +171 -4
- package/package.json +1 -1
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
|
-
#
|
|
73
|
-
#
|
|
74
|
-
#
|
|
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} (
|
|
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} (
|
|
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 (
|
|
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} (
|
|
1397
|
-
`expected 'role:<slug>', 'self',
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
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
|
|
825
|
-
//
|
|
826
|
-
//
|
|
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:
|
|
830
|
+
const { data: emailIntegrations } = await adminDb
|
|
830
831
|
.from('integrations')
|
|
831
|
-
.select('id')
|
|
832
|
-
.
|
|
832
|
+
.select('id, slug, config')
|
|
833
|
+
.in('slug', ['invite-email', 'transactional-email'])
|
|
833
834
|
.eq('is_active', true)
|
|
834
|
-
|
|
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:
|
|
868
|
+
integration_slug: emailIntegration.slug,
|
|
845
869
|
action_config: {
|
|
846
870
|
to: email,
|
|
847
|
-
|
|
871
|
+
subject,
|
|
872
|
+
html,
|
|
848
873
|
invite_url: inviteUrl,
|
|
849
|
-
|
|
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
|
+
})
|