@amalgm/channels 0.1.0 → 0.1.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.
Files changed (127) hide show
  1. package/PURPOSE.md +50 -13
  2. package/README.md +64 -6
  3. package/dist/host/auth.js +7 -3
  4. package/dist/host/auth.js.map +1 -1
  5. package/dist/host/config.d.ts +4 -1
  6. package/dist/host/config.js +4 -1
  7. package/dist/host/config.js.map +1 -1
  8. package/dist/host/inbox-repository.d.ts +10 -0
  9. package/dist/host/inbox-repository.js +57 -0
  10. package/dist/host/inbox-repository.js.map +1 -0
  11. package/dist/host/index.d.ts +4 -0
  12. package/dist/host/index.js +4 -0
  13. package/dist/host/index.js.map +1 -1
  14. package/dist/host/ingress-repository.d.ts +10 -0
  15. package/dist/host/ingress-repository.js +61 -0
  16. package/dist/host/ingress-repository.js.map +1 -0
  17. package/dist/host/main.js +38 -3
  18. package/dist/host/main.js.map +1 -1
  19. package/dist/host/recipient.js.map +1 -1
  20. package/dist/host/repository.d.ts +4 -3
  21. package/dist/host/repository.js +23 -3
  22. package/dist/host/repository.js.map +1 -1
  23. package/dist/host/resend-webhook.d.ts +14 -0
  24. package/dist/host/resend-webhook.js +172 -0
  25. package/dist/host/resend-webhook.js.map +1 -0
  26. package/dist/host/resend.js +4 -0
  27. package/dist/host/resend.js.map +1 -1
  28. package/dist/host/server.d.ts +3 -1
  29. package/dist/host/server.js +87 -19
  30. package/dist/host/server.js.map +1 -1
  31. package/dist/host/wakeups.d.ts +22 -0
  32. package/dist/host/wakeups.js +30 -0
  33. package/dist/host/wakeups.js.map +1 -0
  34. package/dist/src/api-error.d.ts +2 -0
  35. package/dist/src/api-error.js +13 -0
  36. package/dist/src/api-error.js.map +1 -0
  37. package/dist/src/cli/arguments.d.ts +20 -0
  38. package/dist/src/cli/arguments.js +46 -0
  39. package/dist/src/cli/arguments.js.map +1 -0
  40. package/dist/src/cli/input.d.ts +6 -0
  41. package/dist/src/cli/input.js +38 -0
  42. package/dist/src/cli/input.js.map +1 -0
  43. package/dist/src/cli/main.d.ts +7 -0
  44. package/dist/src/cli/main.js +27 -0
  45. package/dist/src/cli/main.js.map +1 -0
  46. package/dist/src/cli/run.d.ts +13 -0
  47. package/dist/src/cli/run.js +47 -0
  48. package/dist/src/cli/run.js.map +1 -0
  49. package/dist/src/cli/shell-runtime.d.ts +9 -0
  50. package/dist/src/cli/shell-runtime.js +106 -0
  51. package/dist/src/cli/shell-runtime.js.map +1 -0
  52. package/dist/src/cli-main.d.ts +2 -0
  53. package/dist/src/cli-main.js +4 -0
  54. package/dist/src/cli-main.js.map +1 -0
  55. package/dist/src/cli.d.ts +3 -0
  56. package/dist/src/cli.js +4 -0
  57. package/dist/src/cli.js.map +1 -0
  58. package/dist/src/client.js +5 -4
  59. package/dist/src/client.js.map +1 -1
  60. package/dist/src/command-surface.d.ts +23 -0
  61. package/dist/src/command-surface.js +53 -0
  62. package/dist/src/command-surface.js.map +1 -0
  63. package/dist/src/contract.d.ts +33 -7
  64. package/dist/src/email/blocks.d.ts +7 -0
  65. package/dist/src/email/blocks.js +43 -0
  66. package/dist/src/email/blocks.js.map +1 -0
  67. package/dist/src/email/escape.d.ts +3 -0
  68. package/dist/src/email/escape.js +19 -0
  69. package/dist/src/email/escape.js.map +1 -0
  70. package/dist/src/email/inbound.d.ts +83 -0
  71. package/dist/src/email/inbound.js +162 -0
  72. package/dist/src/email/inbound.js.map +1 -0
  73. package/dist/src/email/inline.d.ts +1 -0
  74. package/dist/src/email/inline.js +31 -0
  75. package/dist/src/email/inline.js.map +1 -0
  76. package/dist/src/email/markdown.d.ts +1 -0
  77. package/dist/src/email/markdown.js +117 -0
  78. package/dist/src/email/markdown.js.map +1 -0
  79. package/dist/src/email/notification.d.ts +2 -0
  80. package/dist/src/email/notification.js +18 -0
  81. package/dist/src/email/notification.js.map +1 -0
  82. package/dist/src/email/styles.d.ts +33 -0
  83. package/dist/src/email/styles.js +34 -0
  84. package/dist/src/email/styles.js.map +1 -0
  85. package/dist/src/email/system.d.ts +13 -0
  86. package/dist/src/email/system.js +31 -0
  87. package/dist/src/email/system.js.map +1 -0
  88. package/dist/src/email/thread-token.d.ts +8 -0
  89. package/dist/src/email/thread-token.js +70 -0
  90. package/dist/src/email/thread-token.js.map +1 -0
  91. package/dist/src/errors.d.ts +3 -2
  92. package/dist/src/errors.js +3 -1
  93. package/dist/src/errors.js.map +1 -1
  94. package/dist/src/http.d.ts +2 -0
  95. package/dist/src/http.js +84 -3
  96. package/dist/src/http.js.map +1 -1
  97. package/dist/src/inbox/client.d.ts +7 -0
  98. package/dist/src/inbox/client.js +79 -0
  99. package/dist/src/inbox/client.js.map +1 -0
  100. package/dist/src/inbox/contract.d.ts +37 -0
  101. package/dist/src/inbox/contract.js +2 -0
  102. package/dist/src/inbox/contract.js.map +1 -0
  103. package/dist/src/inbox/events.d.ts +7 -0
  104. package/dist/src/inbox/events.js +46 -0
  105. package/dist/src/inbox/events.js.map +1 -0
  106. package/dist/src/inbox/runner.d.ts +14 -0
  107. package/dist/src/inbox/runner.js +93 -0
  108. package/dist/src/inbox/runner.js.map +1 -0
  109. package/dist/src/inbox/service.d.ts +9 -0
  110. package/dist/src/inbox/service.js +56 -0
  111. package/dist/src/inbox/service.js.map +1 -0
  112. package/dist/src/index.d.ts +11 -0
  113. package/dist/src/index.js +11 -0
  114. package/dist/src/index.js.map +1 -1
  115. package/dist/src/mcp.js +20 -52
  116. package/dist/src/mcp.js.map +1 -1
  117. package/dist/src/service.js +7 -3
  118. package/dist/src/service.js.map +1 -1
  119. package/dist/src/validation.d.ts +2 -2
  120. package/dist/src/validation.js +2 -2
  121. package/dist/src/validation.js.map +1 -1
  122. package/dist/src/worker.d.ts +4 -1
  123. package/dist/src/worker.js +27 -5
  124. package/dist/src/worker.js.map +1 -1
  125. package/package.json +38 -12
  126. package/skills/channels/SKILL.md +14 -14
  127. package/supabase/migrations/20260903000000_bidirectional_email.sql +630 -0
@@ -0,0 +1,630 @@
1
+ -- Bidirectional email: provider events are admitted once, one external thread
2
+ -- owns one local Chat session, and only its DPoP-bound computer can claim work.
3
+
4
+ CREATE TABLE IF NOT EXISTS amalgm_channel_threads (
5
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
6
+ user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
7
+ channel text NOT NULL CHECK (channel = 'email'),
8
+ session_id text NOT NULL CHECK (length(session_id) BETWEEN 1 AND 200),
9
+ computer_id uuid REFERENCES user_computers(id) ON DELETE RESTRICT,
10
+ subject text NOT NULL CHECK (length(subject) BETWEEN 1 AND 500),
11
+ last_message_id text,
12
+ message_references text[] NOT NULL DEFAULT '{}',
13
+ created_at timestamptz NOT NULL DEFAULT now(),
14
+ updated_at timestamptz NOT NULL DEFAULT now(),
15
+ UNIQUE (user_id, channel, session_id)
16
+ );
17
+
18
+ ALTER TABLE amalgm_channel_threads ENABLE ROW LEVEL SECURITY;
19
+ REVOKE ALL ON amalgm_channel_threads FROM anon, authenticated;
20
+ GRANT SELECT, INSERT, UPDATE ON amalgm_channel_threads TO service_role;
21
+
22
+ ALTER TABLE amalgm_channel_deliveries
23
+ DROP CONSTRAINT IF EXISTS amalgm_channel_deliveries_status_check;
24
+ ALTER TABLE amalgm_channel_deliveries
25
+ ADD CONSTRAINT amalgm_channel_deliveries_status_check
26
+ CHECK (status IN ('pending', 'sending', 'sent', 'delivered', 'failed')),
27
+ ADD COLUMN IF NOT EXISTS thread_id uuid REFERENCES amalgm_channel_threads(id) ON DELETE RESTRICT,
28
+ ADD COLUMN IF NOT EXISTS session_id text,
29
+ ADD COLUMN IF NOT EXISTS computer_id uuid REFERENCES user_computers(id) ON DELETE RESTRICT,
30
+ ADD COLUMN IF NOT EXISTS rfc_message_id text,
31
+ ADD COLUMN IF NOT EXISTS available_at timestamptz NOT NULL DEFAULT now();
32
+
33
+ CREATE UNIQUE INDEX IF NOT EXISTS amalgm_channel_deliveries_rfc_message
34
+ ON amalgm_channel_deliveries(user_id, rfc_message_id)
35
+ WHERE rfc_message_id IS NOT NULL;
36
+ CREATE INDEX IF NOT EXISTS amalgm_channel_deliveries_provider_message
37
+ ON amalgm_channel_deliveries(user_id, provider_id)
38
+ WHERE provider_id IS NOT NULL;
39
+
40
+ CREATE TABLE IF NOT EXISTS amalgm_channel_inbound (
41
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
42
+ provider_event_id text NOT NULL UNIQUE,
43
+ provider_email_id text NOT NULL UNIQUE,
44
+ provider_message_id text NOT NULL,
45
+ user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE,
46
+ channel text NOT NULL DEFAULT 'email' CHECK (channel = 'email'),
47
+ thread_id uuid REFERENCES amalgm_channel_threads(id) ON DELETE RESTRICT,
48
+ session_id text,
49
+ target_computer_id uuid REFERENCES user_computers(id) ON DELETE RESTRICT,
50
+ starts_session boolean NOT NULL DEFAULT false,
51
+ sender_email text NOT NULL,
52
+ subject text NOT NULL CHECK (length(subject) BETWEEN 1 AND 500),
53
+ message text,
54
+ message_references text[] NOT NULL DEFAULT '{}',
55
+ status text NOT NULL CHECK (status IN ('pending', 'claimed', 'accepted', 'rejected')),
56
+ attempts integer NOT NULL DEFAULT 0 CHECK (attempts >= 0),
57
+ available_at timestamptz NOT NULL DEFAULT now(),
58
+ lease_token uuid,
59
+ lease_expires_at timestamptz,
60
+ error text,
61
+ notice_provider_id text,
62
+ received_at timestamptz NOT NULL,
63
+ accepted_at timestamptz,
64
+ created_at timestamptz NOT NULL DEFAULT now(),
65
+ updated_at timestamptz NOT NULL DEFAULT now(),
66
+ CHECK (
67
+ (status = 'claimed' AND lease_token IS NOT NULL AND lease_expires_at IS NOT NULL AND message IS NOT NULL)
68
+ OR (status = 'pending' AND lease_token IS NULL AND lease_expires_at IS NULL AND message IS NOT NULL)
69
+ OR (status IN ('accepted', 'rejected') AND lease_token IS NULL AND lease_expires_at IS NULL AND message IS NULL)
70
+ )
71
+ );
72
+
73
+ CREATE INDEX IF NOT EXISTS amalgm_channel_inbound_machine_work
74
+ ON amalgm_channel_inbound(target_computer_id, status, available_at, received_at, id)
75
+ WHERE status IN ('pending', 'claimed');
76
+ CREATE INDEX IF NOT EXISTS amalgm_channel_inbound_thread_message
77
+ ON amalgm_channel_inbound(user_id, provider_message_id)
78
+ WHERE user_id IS NOT NULL;
79
+
80
+ ALTER TABLE amalgm_channel_inbound ENABLE ROW LEVEL SECURITY;
81
+ REVOKE ALL ON amalgm_channel_inbound FROM anon, authenticated;
82
+ GRANT SELECT, INSERT, UPDATE ON amalgm_channel_inbound TO service_role;
83
+
84
+ CREATE TABLE IF NOT EXISTS amalgm_channel_provider_events (
85
+ provider_event_id text PRIMARY KEY,
86
+ provider_email_id text NOT NULL,
87
+ event_type text NOT NULL,
88
+ occurred_at timestamptz NOT NULL,
89
+ created_at timestamptz NOT NULL DEFAULT now()
90
+ );
91
+
92
+ ALTER TABLE amalgm_channel_provider_events ENABLE ROW LEVEL SECURITY;
93
+ REVOKE ALL ON amalgm_channel_provider_events FROM anon, authenticated;
94
+ GRANT SELECT, INSERT ON amalgm_channel_provider_events TO service_role;
95
+
96
+ CREATE OR REPLACE FUNCTION amalgm_channel_reference_tail(p_values text[])
97
+ RETURNS text[]
98
+ LANGUAGE sql
99
+ IMMUTABLE
100
+ SET search_path = public
101
+ AS $$
102
+ SELECT COALESCE(array_agg(value ORDER BY last_ordinal), '{}')
103
+ FROM (
104
+ SELECT value, max(ordinal) AS last_ordinal
105
+ FROM unnest(COALESCE(p_values, '{}')) WITH ORDINALITY AS refs(value, ordinal)
106
+ WHERE value IS NOT NULL AND value <> ''
107
+ GROUP BY value
108
+ ORDER BY max(ordinal) DESC
109
+ LIMIT 50
110
+ ) recent;
111
+ $$;
112
+
113
+ CREATE OR REPLACE FUNCTION amalgm_channel_delivery_json(p_row amalgm_channel_deliveries)
114
+ RETURNS jsonb
115
+ LANGUAGE sql
116
+ IMMUTABLE
117
+ SET search_path = public
118
+ AS $$
119
+ SELECT jsonb_strip_nulls(jsonb_build_object(
120
+ 'id', p_row.id,
121
+ 'channel', p_row.channel,
122
+ 'title', p_row.title,
123
+ 'message', p_row.message,
124
+ 'idempotency_key', p_row.idempotency_key,
125
+ 'status', p_row.status,
126
+ 'attempts', p_row.attempts,
127
+ 'created_at', p_row.created_at,
128
+ 'updated_at', p_row.updated_at,
129
+ 'delivered_at', p_row.delivered_at,
130
+ 'provider_id', p_row.provider_id,
131
+ 'thread_id', p_row.thread_id,
132
+ 'session_id', p_row.session_id,
133
+ 'error', p_row.error
134
+ ));
135
+ $$;
136
+
137
+ DROP FUNCTION IF EXISTS enqueue_amalgm_channel_delivery(uuid, jsonb);
138
+ CREATE OR REPLACE FUNCTION enqueue_amalgm_channel_delivery(
139
+ p_user_id uuid,
140
+ p_computer_id uuid,
141
+ p_delivery jsonb,
142
+ p_session_id text
143
+ ) RETURNS jsonb
144
+ LANGUAGE plpgsql
145
+ SECURITY DEFINER
146
+ SET search_path = public, auth
147
+ AS $$
148
+ DECLARE
149
+ v_row public.amalgm_channel_deliveries;
150
+ v_thread public.amalgm_channel_threads;
151
+ v_channel text := CASE WHEN p_delivery->>'channel' = 'auto' THEN 'email' ELSE p_delivery->>'channel' END;
152
+ v_computer_id uuid := p_computer_id;
153
+ v_session_id text := NULLIF(btrim(p_session_id), '');
154
+ v_delivery_id uuid := gen_random_uuid();
155
+ BEGIN
156
+ IF v_channel <> 'email' THEN RAISE EXCEPTION 'unsupported channel' USING ERRCODE = '22023'; END IF;
157
+
158
+ SELECT * INTO v_row
159
+ FROM public.amalgm_channel_deliveries
160
+ WHERE user_id = p_user_id AND idempotency_key = p_delivery->>'idempotencyKey';
161
+ IF FOUND THEN
162
+ IF v_row.channel <> v_channel
163
+ OR v_row.title IS DISTINCT FROM NULLIF(p_delivery->>'title', '')
164
+ OR v_row.message <> p_delivery->>'message'
165
+ OR (v_session_id IS NOT NULL AND v_row.session_id IS DISTINCT FROM v_session_id)
166
+ THEN
167
+ RAISE EXCEPTION 'idempotency key already names another delivery' USING ERRCODE = '23505';
168
+ END IF;
169
+ RETURN public.amalgm_channel_delivery_json(v_row);
170
+ END IF;
171
+
172
+ IF v_session_id IS NOT NULL THEN
173
+ SELECT * INTO v_thread FROM public.amalgm_channel_threads
174
+ WHERE user_id = p_user_id AND channel = v_channel AND session_id = v_session_id;
175
+ END IF;
176
+
177
+ IF NOT FOUND THEN
178
+ IF v_computer_id IS NOT NULL AND NOT EXISTS (
179
+ SELECT 1 FROM public.user_computers WHERE id = v_computer_id AND user_id = p_user_id
180
+ ) THEN
181
+ RAISE EXCEPTION 'computer does not belong to user' USING ERRCODE = '22023';
182
+ END IF;
183
+ IF v_computer_id IS NULL THEN
184
+ SELECT COALESCE(up.last_used_computer_id, up.default_computer_id) INTO v_computer_id
185
+ FROM public.user_preferences up WHERE up.user_id = p_user_id;
186
+ END IF;
187
+ IF v_computer_id IS NULL THEN
188
+ SELECT id INTO v_computer_id FROM public.user_computers
189
+ WHERE user_id = p_user_id ORDER BY last_seen_at DESC NULLS LAST, updated_at DESC LIMIT 1;
190
+ END IF;
191
+ IF v_computer_id IS NULL THEN RAISE EXCEPTION 'user has no computer' USING ERRCODE = '22023'; END IF;
192
+ v_session_id := COALESCE(v_session_id, gen_random_uuid()::text);
193
+ INSERT INTO public.amalgm_channel_threads(user_id, channel, session_id, computer_id, subject)
194
+ VALUES (p_user_id, v_channel, v_session_id, v_computer_id, p_delivery->>'title')
195
+ ON CONFLICT (user_id, channel, session_id) DO UPDATE
196
+ SET session_id = EXCLUDED.session_id
197
+ RETURNING * INTO v_thread;
198
+ END IF;
199
+
200
+ INSERT INTO public.amalgm_channel_deliveries(
201
+ id, user_id, channel, title, message, idempotency_key,
202
+ thread_id, session_id, computer_id, rfc_message_id
203
+ ) VALUES (
204
+ v_delivery_id, p_user_id, v_channel, NULLIF(p_delivery->>'title', ''),
205
+ p_delivery->>'message', p_delivery->>'idempotencyKey',
206
+ v_thread.id, v_thread.session_id, v_thread.computer_id,
207
+ '<' || v_delivery_id::text || '@amalgm.ai>'
208
+ )
209
+ ON CONFLICT (user_id, idempotency_key) DO NOTHING
210
+ RETURNING * INTO v_row;
211
+ IF NOT FOUND THEN
212
+ SELECT * INTO v_row FROM public.amalgm_channel_deliveries
213
+ WHERE user_id = p_user_id AND idempotency_key = p_delivery->>'idempotencyKey';
214
+ IF v_row.channel <> v_channel
215
+ OR v_row.title IS DISTINCT FROM NULLIF(p_delivery->>'title', '')
216
+ OR v_row.message <> p_delivery->>'message'
217
+ OR v_row.session_id IS DISTINCT FROM v_thread.session_id
218
+ THEN
219
+ RAISE EXCEPTION 'idempotency key already names another delivery' USING ERRCODE = '23505';
220
+ END IF;
221
+ END IF;
222
+ RETURN public.amalgm_channel_delivery_json(v_row);
223
+ END;
224
+ $$;
225
+
226
+ DROP FUNCTION IF EXISTS claim_amalgm_channel_deliveries(integer, integer);
227
+ CREATE OR REPLACE FUNCTION claim_amalgm_channel_deliveries(
228
+ p_limit integer,
229
+ p_lease_seconds integer
230
+ ) RETURNS TABLE (
231
+ id uuid, user_id uuid, channel text, title text, message text,
232
+ idempotency_key text, lease_token uuid, attempts integer,
233
+ thread_id uuid, session_id text, thread_subject text, rfc_message_id text,
234
+ reply_to_message_id text, "references" text[]
235
+ )
236
+ LANGUAGE sql
237
+ SECURITY DEFINER
238
+ SET search_path = public
239
+ AS $$
240
+ WITH candidates AS (
241
+ SELECT d.id
242
+ FROM public.amalgm_channel_deliveries d
243
+ WHERE d.attempts < 5
244
+ AND (
245
+ (d.status = 'pending' AND d.available_at <= now())
246
+ OR (d.status = 'sending' AND d.lease_expires_at <= now())
247
+ )
248
+ ORDER BY d.available_at, d.created_at, d.id
249
+ FOR UPDATE SKIP LOCKED
250
+ LIMIT LEAST(GREATEST(p_limit, 1), 100)
251
+ ), claimed AS (
252
+ UPDATE public.amalgm_channel_deliveries d SET
253
+ status = 'sending', attempts = d.attempts + 1,
254
+ lease_token = gen_random_uuid(),
255
+ lease_expires_at = now() + make_interval(secs => LEAST(GREATEST(p_lease_seconds, 10), 600)),
256
+ updated_at = now()
257
+ FROM candidates WHERE d.id = candidates.id
258
+ RETURNING d.*
259
+ )
260
+ SELECT c.id, c.user_id, c.channel, c.title, c.message,
261
+ c.idempotency_key, c.lease_token, c.attempts,
262
+ t.id, t.session_id, t.subject, c.rfc_message_id,
263
+ t.last_message_id, t.message_references
264
+ FROM claimed c JOIN public.amalgm_channel_threads t ON t.id = c.thread_id;
265
+ $$;
266
+
267
+ DROP FUNCTION IF EXISTS complete_amalgm_channel_delivery(uuid, uuid, text, timestamptz);
268
+ CREATE OR REPLACE FUNCTION mark_amalgm_channel_delivery_sent(
269
+ p_delivery_id uuid,
270
+ p_lease_token uuid,
271
+ p_provider_id text,
272
+ p_now timestamptz
273
+ ) RETURNS boolean
274
+ LANGUAGE plpgsql
275
+ SECURITY DEFINER
276
+ SET search_path = public
277
+ AS $$
278
+ BEGIN
279
+ UPDATE public.amalgm_channel_deliveries SET
280
+ status = 'sent', provider_id = left(p_provider_id, 500), error = NULL,
281
+ lease_token = NULL, lease_expires_at = NULL, updated_at = p_now
282
+ WHERE id = p_delivery_id AND status = 'sending' AND lease_token = p_lease_token;
283
+ RETURN FOUND;
284
+ END;
285
+ $$;
286
+
287
+ CREATE OR REPLACE FUNCTION fail_amalgm_channel_delivery(
288
+ p_delivery_id uuid,
289
+ p_lease_token uuid,
290
+ p_error text,
291
+ p_now timestamptz
292
+ ) RETURNS boolean
293
+ LANGUAGE plpgsql
294
+ SECURITY DEFINER
295
+ SET search_path = public
296
+ AS $$
297
+ BEGIN
298
+ UPDATE public.amalgm_channel_deliveries SET
299
+ status = CASE WHEN attempts >= 5 THEN 'failed' ELSE 'pending' END,
300
+ available_at = p_now + make_interval(secs => LEAST(300, 5 * (2 ^ LEAST(attempts, 6))::integer)),
301
+ error = left(p_error, 500), lease_token = NULL, lease_expires_at = NULL,
302
+ updated_at = p_now
303
+ WHERE id = p_delivery_id AND status = 'sending' AND lease_token = p_lease_token;
304
+ RETURN FOUND;
305
+ END;
306
+ $$;
307
+
308
+ CREATE OR REPLACE FUNCTION next_amalgm_channel_delivery_at()
309
+ RETURNS timestamptz
310
+ LANGUAGE sql
311
+ SECURITY DEFINER
312
+ SET search_path = public
313
+ AS $$
314
+ SELECT min(ready_at) FROM (
315
+ SELECT available_at AS ready_at FROM public.amalgm_channel_deliveries
316
+ WHERE status = 'pending' AND attempts < 5
317
+ UNION ALL
318
+ SELECT lease_expires_at FROM public.amalgm_channel_deliveries
319
+ WHERE status = 'sending' AND attempts < 5
320
+ ) work;
321
+ $$;
322
+
323
+ CREATE OR REPLACE FUNCTION admit_amalgm_channel_email(p_email jsonb)
324
+ RETURNS jsonb
325
+ LANGUAGE plpgsql
326
+ SECURITY DEFINER
327
+ SET search_path = public, auth
328
+ AS $$
329
+ DECLARE
330
+ v_existing public.amalgm_channel_inbound;
331
+ v_user_id uuid;
332
+ v_thread public.amalgm_channel_threads;
333
+ v_message public.amalgm_channel_inbound;
334
+ v_computer_id uuid;
335
+ v_computer_name text;
336
+ v_online boolean := false;
337
+ v_starts_session boolean := false;
338
+ v_reply_id text;
339
+ BEGIN
340
+ SELECT * INTO v_existing FROM public.amalgm_channel_inbound
341
+ WHERE provider_email_id = p_email->>'providerEmailId'
342
+ OR provider_event_id = p_email->>'providerEventId'
343
+ LIMIT 1;
344
+ IF FOUND THEN
345
+ IF v_existing.status = 'rejected' THEN
346
+ RETURN jsonb_build_object(
347
+ 'kind', CASE WHEN v_existing.user_id IS NULL THEN 'unknown_sender' ELSE 'no_computer' END,
348
+ 'record_id', v_existing.id, 'sender_email', v_existing.sender_email,
349
+ 'subject', v_existing.subject, 'provider_message_id', v_existing.provider_message_id,
350
+ 'references', v_existing.message_references
351
+ );
352
+ END IF;
353
+ RETURN jsonb_build_object(
354
+ 'kind', 'accepted', 'user_id', v_existing.user_id, 'message_id', v_existing.id,
355
+ 'thread_id', v_existing.thread_id, 'session_id', v_existing.session_id,
356
+ 'target_computer_id', v_existing.target_computer_id,
357
+ 'target_computer_name', COALESCE((SELECT name FROM public.user_computers WHERE id = v_existing.target_computer_id), 'Your computer'),
358
+ 'target_online', EXISTS(SELECT 1 FROM public.user_computers WHERE id = v_existing.target_computer_id AND status = 'online'),
359
+ 'sender_email', v_existing.sender_email, 'subject', v_existing.subject,
360
+ 'provider_message_id', v_existing.provider_message_id, 'references', v_existing.message_references
361
+ );
362
+ END IF;
363
+
364
+ SELECT id INTO v_user_id FROM auth.users
365
+ WHERE lower(email) = lower(p_email->>'senderEmail') LIMIT 1;
366
+ IF v_user_id IS NULL THEN
367
+ INSERT INTO public.amalgm_channel_inbound(
368
+ provider_event_id, provider_email_id, provider_message_id,
369
+ sender_email, subject, message, message_references, status, received_at
370
+ ) VALUES (
371
+ p_email->>'providerEventId', p_email->>'providerEmailId', p_email->>'providerMessageId',
372
+ p_email->>'senderEmail', p_email->>'subject', NULL,
373
+ COALESCE(ARRAY(SELECT jsonb_array_elements_text(p_email->'replyMessageIds')), '{}'),
374
+ 'rejected', (p_email->>'receivedAt')::timestamptz
375
+ ) RETURNING * INTO v_message;
376
+ RETURN jsonb_build_object(
377
+ 'kind', 'unknown_sender', 'record_id', v_message.id,
378
+ 'sender_email', v_message.sender_email, 'subject', v_message.subject,
379
+ 'provider_message_id', v_message.provider_message_id, 'references', v_message.message_references
380
+ );
381
+ END IF;
382
+
383
+ -- A signed reply address narrows a real reply; copying that address into a
384
+ -- fresh composition does not turn the new root into the old thread.
385
+ IF p_email ? 'threadHintId'
386
+ AND jsonb_array_length(COALESCE(p_email->'replyMessageIds', '[]'::jsonb)) > 0
387
+ THEN
388
+ SELECT * INTO v_thread FROM public.amalgm_channel_threads
389
+ WHERE id = (p_email->>'threadHintId')::uuid AND user_id = v_user_id AND channel = 'email';
390
+ END IF;
391
+ IF v_thread.id IS NULL THEN
392
+ FOR v_reply_id IN SELECT jsonb_array_elements_text(COALESCE(p_email->'replyMessageIds', '[]'::jsonb)) LOOP
393
+ SELECT t.* INTO v_thread
394
+ FROM public.amalgm_channel_threads t
395
+ WHERE t.user_id = v_user_id AND t.channel = 'email' AND (
396
+ EXISTS(SELECT 1 FROM public.amalgm_channel_deliveries d
397
+ WHERE d.thread_id = t.id AND (d.rfc_message_id = v_reply_id OR d.provider_id = v_reply_id))
398
+ OR EXISTS(SELECT 1 FROM public.amalgm_channel_inbound i
399
+ WHERE i.thread_id = t.id AND i.provider_message_id = v_reply_id)
400
+ ) LIMIT 1;
401
+ EXIT WHEN v_thread.id IS NOT NULL;
402
+ END LOOP;
403
+ END IF;
404
+
405
+ IF v_thread.id IS NULL THEN
406
+ SELECT COALESCE(up.last_used_computer_id, up.default_computer_id) INTO v_computer_id
407
+ FROM public.user_preferences up WHERE up.user_id = v_user_id;
408
+ IF v_computer_id IS NULL THEN
409
+ SELECT id INTO v_computer_id FROM public.user_computers
410
+ WHERE user_id = v_user_id ORDER BY last_seen_at DESC NULLS LAST, updated_at DESC LIMIT 1;
411
+ END IF;
412
+ IF v_computer_id IS NULL THEN
413
+ INSERT INTO public.amalgm_channel_inbound(
414
+ provider_event_id, provider_email_id, provider_message_id, user_id,
415
+ sender_email, subject, message, message_references, status, received_at
416
+ ) VALUES (
417
+ p_email->>'providerEventId', p_email->>'providerEmailId', p_email->>'providerMessageId', v_user_id,
418
+ p_email->>'senderEmail', p_email->>'subject', NULL,
419
+ COALESCE(ARRAY(SELECT jsonb_array_elements_text(p_email->'replyMessageIds')), '{}'),
420
+ 'rejected', (p_email->>'receivedAt')::timestamptz
421
+ ) RETURNING * INTO v_message;
422
+ RETURN jsonb_build_object(
423
+ 'kind', 'no_computer', 'record_id', v_message.id,
424
+ 'sender_email', v_message.sender_email, 'subject', v_message.subject,
425
+ 'provider_message_id', v_message.provider_message_id, 'references', v_message.message_references
426
+ );
427
+ END IF;
428
+ INSERT INTO public.amalgm_channel_threads(user_id, channel, session_id, computer_id, subject)
429
+ VALUES (v_user_id, 'email', gen_random_uuid()::text, v_computer_id, p_email->>'subject')
430
+ RETURNING * INTO v_thread;
431
+ v_starts_session := true;
432
+ END IF;
433
+
434
+ v_computer_id := v_thread.computer_id;
435
+ SELECT name, status = 'online' INTO v_computer_name, v_online
436
+ FROM public.user_computers WHERE id = v_computer_id AND user_id = v_user_id;
437
+
438
+ INSERT INTO public.amalgm_channel_inbound(
439
+ provider_event_id, provider_email_id, provider_message_id, user_id,
440
+ thread_id, session_id, target_computer_id, starts_session,
441
+ sender_email, subject, message, message_references, status, received_at
442
+ ) VALUES (
443
+ p_email->>'providerEventId', p_email->>'providerEmailId', p_email->>'providerMessageId', v_user_id,
444
+ v_thread.id, v_thread.session_id, v_computer_id, v_starts_session,
445
+ p_email->>'senderEmail', p_email->>'subject', p_email->>'message',
446
+ COALESCE(ARRAY(SELECT jsonb_array_elements_text(p_email->'replyMessageIds')), '{}'),
447
+ 'pending', (p_email->>'receivedAt')::timestamptz
448
+ ) RETURNING * INTO v_message;
449
+
450
+ UPDATE public.amalgm_channel_threads SET
451
+ last_message_id = v_message.provider_message_id,
452
+ message_references = public.amalgm_channel_reference_tail(
453
+ message_references || v_message.message_references || ARRAY[v_message.provider_message_id]
454
+ ),
455
+ updated_at = now()
456
+ WHERE id = v_thread.id;
457
+
458
+ RETURN jsonb_build_object(
459
+ 'kind', 'accepted', 'user_id', v_user_id, 'message_id', v_message.id,
460
+ 'thread_id', v_thread.id, 'session_id', v_thread.session_id,
461
+ 'target_computer_id', v_computer_id, 'target_computer_name', COALESCE(v_computer_name, 'Your computer'),
462
+ 'target_online', COALESCE(v_online, false), 'sender_email', v_message.sender_email,
463
+ 'subject', v_message.subject, 'provider_message_id', v_message.provider_message_id,
464
+ 'references', v_message.message_references
465
+ );
466
+ EXCEPTION WHEN unique_violation THEN
467
+ SELECT * INTO v_existing FROM public.amalgm_channel_inbound
468
+ WHERE provider_email_id = p_email->>'providerEmailId' OR provider_event_id = p_email->>'providerEventId' LIMIT 1;
469
+ RETURN jsonb_build_object('kind', 'duplicate', 'record_id', v_existing.id);
470
+ END;
471
+ $$;
472
+
473
+ CREATE OR REPLACE FUNCTION claim_amalgm_channel_inbound(
474
+ p_user_id uuid,
475
+ p_computer_id uuid,
476
+ p_limit integer,
477
+ p_lease_seconds integer
478
+ ) RETURNS SETOF amalgm_channel_inbound
479
+ LANGUAGE sql
480
+ SECURITY DEFINER
481
+ SET search_path = public
482
+ AS $$
483
+ WITH candidates AS (
484
+ SELECT i.id
485
+ FROM public.amalgm_channel_inbound i
486
+ WHERE i.user_id = p_user_id AND i.target_computer_id = p_computer_id
487
+ AND i.available_at <= now()
488
+ AND (i.status = 'pending' OR (i.status = 'claimed' AND i.lease_expires_at <= now()))
489
+ AND NOT EXISTS (
490
+ SELECT 1 FROM public.amalgm_channel_inbound earlier
491
+ WHERE earlier.thread_id = i.thread_id AND earlier.id <> i.id
492
+ AND earlier.status IN ('pending', 'claimed')
493
+ AND (earlier.received_at, earlier.id) < (i.received_at, i.id)
494
+ )
495
+ ORDER BY i.received_at, i.id
496
+ FOR UPDATE SKIP LOCKED
497
+ LIMIT LEAST(GREATEST(p_limit, 1), 10)
498
+ )
499
+ UPDATE public.amalgm_channel_inbound i SET
500
+ status = 'claimed', attempts = i.attempts + 1,
501
+ lease_token = gen_random_uuid(),
502
+ lease_expires_at = now() + make_interval(secs => LEAST(GREATEST(p_lease_seconds, 30), 900)),
503
+ updated_at = now()
504
+ FROM candidates WHERE i.id = candidates.id
505
+ RETURNING i.*;
506
+ $$;
507
+
508
+ CREATE OR REPLACE FUNCTION accept_amalgm_channel_inbound(
509
+ p_user_id uuid, p_computer_id uuid, p_message_id uuid,
510
+ p_lease_token uuid, p_now timestamptz
511
+ ) RETURNS boolean
512
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
513
+ AS $$
514
+ BEGIN
515
+ UPDATE public.amalgm_channel_inbound SET
516
+ status = 'accepted', message = NULL, accepted_at = p_now,
517
+ lease_token = NULL, lease_expires_at = NULL, error = NULL, updated_at = p_now
518
+ WHERE id = p_message_id AND user_id = p_user_id AND target_computer_id = p_computer_id
519
+ AND status = 'claimed' AND lease_token = p_lease_token;
520
+ RETURN FOUND;
521
+ END;
522
+ $$;
523
+
524
+ CREATE OR REPLACE FUNCTION release_amalgm_channel_inbound(
525
+ p_user_id uuid, p_computer_id uuid, p_message_id uuid,
526
+ p_lease_token uuid, p_error text, p_now timestamptz
527
+ ) RETURNS boolean
528
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
529
+ AS $$
530
+ BEGIN
531
+ UPDATE public.amalgm_channel_inbound SET
532
+ status = 'pending', available_at = p_now + make_interval(secs => LEAST(60, (2 ^ LEAST(attempts, 5))::integer)),
533
+ lease_token = NULL, lease_expires_at = NULL, error = left(p_error, 500), updated_at = p_now
534
+ WHERE id = p_message_id AND user_id = p_user_id AND target_computer_id = p_computer_id
535
+ AND status = 'claimed' AND lease_token = p_lease_token;
536
+ RETURN FOUND;
537
+ END;
538
+ $$;
539
+
540
+ CREATE OR REPLACE FUNCTION record_amalgm_channel_inbound_notice(
541
+ p_message_id uuid, p_provider_id text, p_now timestamptz
542
+ ) RETURNS boolean
543
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
544
+ AS $$
545
+ BEGIN
546
+ UPDATE public.amalgm_channel_inbound SET
547
+ notice_provider_id = COALESCE(notice_provider_id, left(p_provider_id, 500)), updated_at = p_now
548
+ WHERE id = p_message_id;
549
+ RETURN FOUND;
550
+ END;
551
+ $$;
552
+
553
+ CREATE OR REPLACE FUNCTION apply_amalgm_channel_provider_event(p_event jsonb)
554
+ RETURNS boolean
555
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
556
+ AS $$
557
+ DECLARE
558
+ v_delivery public.amalgm_channel_deliveries;
559
+ v_type text := p_event->>'type';
560
+ v_message_id text := NULLIF(p_event->>'providerMessageId', '');
561
+ BEGIN
562
+ INSERT INTO public.amalgm_channel_provider_events(provider_event_id, provider_email_id, event_type, occurred_at)
563
+ VALUES (p_event->>'providerEventId', p_event->>'providerEmailId', v_type, (p_event->>'occurredAt')::timestamptz)
564
+ ON CONFLICT DO NOTHING;
565
+ IF NOT FOUND THEN RETURN false; END IF;
566
+
567
+ SELECT * INTO v_delivery FROM public.amalgm_channel_deliveries
568
+ WHERE provider_id = p_event->>'providerEmailId' LIMIT 1 FOR UPDATE;
569
+ IF NOT FOUND THEN RETURN true; END IF;
570
+
571
+ UPDATE public.amalgm_channel_deliveries SET
572
+ status = CASE
573
+ WHEN status = 'delivered' THEN status
574
+ WHEN v_type = 'delivered' THEN 'delivered'
575
+ WHEN v_type = 'failed' THEN 'failed'
576
+ ELSE 'sent'
577
+ END,
578
+ delivered_at = CASE WHEN v_type = 'delivered' THEN (p_event->>'occurredAt')::timestamptz ELSE delivered_at END,
579
+ rfc_message_id = COALESCE(v_message_id, rfc_message_id),
580
+ error = CASE WHEN v_type = 'failed' THEN left(p_event->>'error', 500) ELSE NULL END,
581
+ updated_at = (p_event->>'occurredAt')::timestamptz
582
+ WHERE id = v_delivery.id;
583
+
584
+ IF v_message_id IS NOT NULL THEN
585
+ UPDATE public.amalgm_channel_threads SET
586
+ last_message_id = v_message_id,
587
+ message_references = public.amalgm_channel_reference_tail(message_references || ARRAY[v_message_id]),
588
+ updated_at = now()
589
+ WHERE id = v_delivery.thread_id;
590
+ END IF;
591
+ RETURN true;
592
+ END;
593
+ $$;
594
+
595
+ DO $$
596
+ BEGIN
597
+ IF EXISTS (SELECT 1 FROM pg_publication WHERE pubname = 'supabase_realtime')
598
+ AND NOT EXISTS (
599
+ SELECT 1 FROM pg_publication_tables
600
+ WHERE pubname = 'supabase_realtime' AND schemaname = 'public' AND tablename = 'amalgm_channel_inbound'
601
+ )
602
+ THEN
603
+ ALTER PUBLICATION supabase_realtime ADD TABLE amalgm_channel_inbound;
604
+ END IF;
605
+ END $$;
606
+
607
+ REVOKE ALL ON FUNCTION enqueue_amalgm_channel_delivery(uuid, uuid, jsonb, text) FROM PUBLIC;
608
+ REVOKE ALL ON FUNCTION amalgm_channel_reference_tail(text[]) FROM PUBLIC;
609
+ REVOKE ALL ON FUNCTION claim_amalgm_channel_deliveries(integer, integer) FROM PUBLIC;
610
+ REVOKE ALL ON FUNCTION mark_amalgm_channel_delivery_sent(uuid, uuid, text, timestamptz) FROM PUBLIC;
611
+ REVOKE ALL ON FUNCTION fail_amalgm_channel_delivery(uuid, uuid, text, timestamptz) FROM PUBLIC;
612
+ REVOKE ALL ON FUNCTION next_amalgm_channel_delivery_at() FROM PUBLIC;
613
+ REVOKE ALL ON FUNCTION admit_amalgm_channel_email(jsonb) FROM PUBLIC;
614
+ REVOKE ALL ON FUNCTION claim_amalgm_channel_inbound(uuid, uuid, integer, integer) FROM PUBLIC;
615
+ REVOKE ALL ON FUNCTION accept_amalgm_channel_inbound(uuid, uuid, uuid, uuid, timestamptz) FROM PUBLIC;
616
+ REVOKE ALL ON FUNCTION release_amalgm_channel_inbound(uuid, uuid, uuid, uuid, text, timestamptz) FROM PUBLIC;
617
+ REVOKE ALL ON FUNCTION record_amalgm_channel_inbound_notice(uuid, text, timestamptz) FROM PUBLIC;
618
+ REVOKE ALL ON FUNCTION apply_amalgm_channel_provider_event(jsonb) FROM PUBLIC;
619
+
620
+ GRANT EXECUTE ON FUNCTION enqueue_amalgm_channel_delivery(uuid, uuid, jsonb, text) TO service_role;
621
+ GRANT EXECUTE ON FUNCTION claim_amalgm_channel_deliveries(integer, integer) TO service_role;
622
+ GRANT EXECUTE ON FUNCTION mark_amalgm_channel_delivery_sent(uuid, uuid, text, timestamptz) TO service_role;
623
+ GRANT EXECUTE ON FUNCTION fail_amalgm_channel_delivery(uuid, uuid, text, timestamptz) TO service_role;
624
+ GRANT EXECUTE ON FUNCTION next_amalgm_channel_delivery_at() TO service_role;
625
+ GRANT EXECUTE ON FUNCTION admit_amalgm_channel_email(jsonb) TO service_role;
626
+ GRANT EXECUTE ON FUNCTION claim_amalgm_channel_inbound(uuid, uuid, integer, integer) TO service_role;
627
+ GRANT EXECUTE ON FUNCTION accept_amalgm_channel_inbound(uuid, uuid, uuid, uuid, timestamptz) TO service_role;
628
+ GRANT EXECUTE ON FUNCTION release_amalgm_channel_inbound(uuid, uuid, uuid, uuid, text, timestamptz) TO service_role;
629
+ GRANT EXECUTE ON FUNCTION record_amalgm_channel_inbound_notice(uuid, text, timestamptz) TO service_role;
630
+ GRANT EXECUTE ON FUNCTION apply_amalgm_channel_provider_event(jsonb) TO service_role;