ugly-app 0.1.175 → 0.1.176

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.
@@ -1,2 +1,2 @@
1
- export declare const CLI_VERSION = "0.1.175";
1
+ export declare const CLI_VERSION = "0.1.176";
2
2
  //# sourceMappingURL=version.d.ts.map
@@ -1,3 +1,3 @@
1
1
  // Auto-generated by prebuild — do not edit manually
2
- export const CLI_VERSION = "0.1.175";
2
+ export const CLI_VERSION = "0.1.176";
3
3
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ugly-app",
3
- "version": "0.1.175",
3
+ "version": "0.1.176",
4
4
  "type": "module",
5
5
  "main": "./dist/server/index.js",
6
6
  "exports": {
@@ -1,2 +1,2 @@
1
1
  // Auto-generated by prebuild — do not edit manually
2
- export const CLI_VERSION = "0.1.175";
2
+ export const CLI_VERSION = "0.1.176";
@@ -25,4 +25,5 @@ export const allPages = {
25
25
  ['kagi-test']: lazyPage(() => import('./pages/KagiTestPage')),
26
26
  ['audio-test']: lazyPage(() => import('./pages/AudioTestPage')),
27
27
  ['upload-test']: lazyPage(() => import('./pages/UploadTestPage')),
28
+ ['email-test']: lazyPage(() => import('./pages/EmailTestPage')),
28
29
  } satisfies PageMap<AppPages>;
@@ -0,0 +1,150 @@
1
+ import React, { useState } from 'react';
2
+ import { Button, Card, PageLayout, useApp } from 'ugly-app/client';
3
+
4
+ interface SentEntry {
5
+ to: string;
6
+ subject: string;
7
+ id: string | null;
8
+ sentAt: string;
9
+ }
10
+
11
+ export default function EmailTestPage(): React.ReactElement {
12
+ const { socket } = useApp();
13
+ const [to, setTo] = useState('');
14
+ const [subject, setSubject] = useState('Test email from ugly-app');
15
+ const [html, setHtml] = useState('<h1>Hello!</h1>\n<p>This is a test email sent from ugly-app.</p>');
16
+ const [replyId, setReplyId] = useState('');
17
+ const [sending, setSending] = useState(false);
18
+ const [error, setError] = useState('');
19
+ const [sent, setSent] = useState<SentEntry[]>([]);
20
+
21
+ async function handleSend(): Promise<void> {
22
+ const trimmedTo = to.trim();
23
+ if (!trimmedTo) return;
24
+
25
+ setSending(true);
26
+ setError('');
27
+ try {
28
+ await socket.request('sendTestEmail', {
29
+ to: trimmedTo,
30
+ subject: subject.trim() || 'Test email',
31
+ html,
32
+ id: replyId.trim() || undefined,
33
+ });
34
+ setSent((prev) => [
35
+ { to: trimmedTo, subject, id: replyId.trim() || null, sentAt: new Date().toISOString() },
36
+ ...prev,
37
+ ]);
38
+ setTo('');
39
+ } catch (err) {
40
+ setError(err instanceof Error ? err.message : String(err));
41
+ } finally {
42
+ setSending(false);
43
+ }
44
+ }
45
+
46
+ return (
47
+ <PageLayout
48
+ header={
49
+ <div>
50
+ <a href="/">← Home</a>
51
+ </div>
52
+ }
53
+ >
54
+ <div>
55
+ <h1>Email Test</h1>
56
+
57
+ <Card>
58
+ <h2>Send Email</h2>
59
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
60
+ <label>
61
+ <div style={{ marginBottom: 4, fontWeight: 500 }}>To</div>
62
+ <input
63
+ type="email"
64
+ value={to}
65
+ onChange={(e) => { setTo(e.target.value); }}
66
+ placeholder="recipient@example.com"
67
+ style={{ width: '100%', padding: 8, borderRadius: 4, border: '1px solid #444' }}
68
+ />
69
+ </label>
70
+ <label>
71
+ <div style={{ marginBottom: 4, fontWeight: 500 }}>Subject</div>
72
+ <input
73
+ type="text"
74
+ value={subject}
75
+ onChange={(e) => { setSubject(e.target.value); }}
76
+ placeholder="Email subject"
77
+ style={{ width: '100%', padding: 8, borderRadius: 4, border: '1px solid #444' }}
78
+ />
79
+ </label>
80
+ <label>
81
+ <div style={{ marginBottom: 4, fontWeight: 500 }}>Reply ID (optional)</div>
82
+ <input
83
+ type="text"
84
+ value={replyId}
85
+ onChange={(e) => { setReplyId(e.target.value); }}
86
+ placeholder="e.g. order-123"
87
+ style={{ width: '100%', padding: 8, borderRadius: 4, border: '1px solid #444' }}
88
+ />
89
+ <div style={{ fontSize: '0.8em', color: '#888', marginTop: 2 }}>
90
+ Sets the +tag in the from address for reply correlation
91
+ </div>
92
+ </label>
93
+ <label>
94
+ <div style={{ marginBottom: 4, fontWeight: 500 }}>HTML Body</div>
95
+ <textarea
96
+ value={html}
97
+ onChange={(e) => { setHtml(e.target.value); }}
98
+ rows={6}
99
+ style={{ width: '100%', padding: 8, borderRadius: 4, border: '1px solid #444', fontFamily: 'monospace', fontSize: '0.9em' }}
100
+ />
101
+ </label>
102
+ <Button label="Send Email" onClick={() => { void handleSend(); }} disabled={sending || !to.trim()} />
103
+ {sending && <p>Sending…</p>}
104
+ {error && <p style={{ color: '#f44' }}>{error}</p>}
105
+ </div>
106
+ </Card>
107
+
108
+ {sent.length > 0 && (
109
+ <Card>
110
+ <h2>Sent Emails</h2>
111
+ {sent.map((entry, i) => (
112
+ <div key={i} style={{ marginBottom: 12, paddingBottom: 12, borderBottom: '1px solid #333' }}>
113
+ <div><strong>To:</strong> {entry.to}</div>
114
+ <div><strong>Subject:</strong> {entry.subject}</div>
115
+ {entry.id && <div><strong>Reply ID:</strong> {entry.id}</div>}
116
+ <div style={{ fontSize: '0.8em', color: '#888' }}>{entry.sentAt}</div>
117
+ </div>
118
+ ))}
119
+ </Card>
120
+ )}
121
+
122
+ <Card>
123
+ <h2>How it works</h2>
124
+ <ol>
125
+ <li><code>createEmailSender(domain)</code> creates a scoped sender for your app</li>
126
+ <li>Emails are sent from <code>{'<domain>'}@ugly.bot</code> via Mailgun</li>
127
+ <li>If a reply ID is set, the from address becomes <code>{'<domain>'}+{'<id>'}@ugly.bot</code></li>
128
+ <li>Replies are routed back to your app via <code>setOnEmail()</code> handler</li>
129
+ </ol>
130
+ <pre style={{ background: '#1a1a1a', padding: 12, borderRadius: 4, fontSize: '0.85em', overflow: 'auto' }}>{`// server/index.ts
131
+ import { createEmailSender } from 'ugly-app';
132
+
133
+ const email = createEmailSender(process.env.APP_DOMAIN!);
134
+
135
+ await email.send({
136
+ to: 'user@example.com',
137
+ subject: 'Hello',
138
+ html: '<p>Hi there!</p>',
139
+ id: 'order-123', // optional reply correlation
140
+ });
141
+
142
+ // Receive replies:
143
+ configurator.setOnEmail(async (inbound) => {
144
+ console.log(inbound.from, inbound.id, inbound.text);
145
+ });`}</pre>
146
+ </Card>
147
+ </div>
148
+ </PageLayout>
149
+ );
150
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Push notification helpers for projects deployed on ugly.bot.
3
+ *
4
+ * Registration happens via a hidden iframe on ugly.bot (which owns the
5
+ * service worker and Firebase credentials). Projects only need their
6
+ * project token (window.__AUTH_TOKEN__) to authenticate.
7
+ *
8
+ * Usage:
9
+ * import { initPush, requestPushPermission } from './push';
10
+ *
11
+ * // Check status (call once on app load)
12
+ * const { registered } = await initPush();
13
+ *
14
+ * // Prompt user if not registered
15
+ * if (!registered) await requestPushPermission();
16
+ */
17
+
18
+ const PUSH_FRAME_URL = 'https://ugly.bot/push-frame';
19
+ const PUSH_FRAME_ORIGIN = 'https://ugly.bot';
20
+
21
+ let iframe: HTMLIFrameElement | null = null;
22
+ let iframeReady = false;
23
+ let pendingResolvers: Array<{
24
+ func: string;
25
+ resolve: (data: Record<string, unknown>) => void;
26
+ }> = [];
27
+
28
+ function getToken(): string {
29
+ return (window as unknown as { __AUTH_TOKEN__?: string }).__AUTH_TOKEN__ ?? '';
30
+ }
31
+
32
+ function ensureIframe(): Promise<void> {
33
+ if (iframe && iframeReady) return Promise.resolve();
34
+ return new Promise((resolve) => {
35
+ if (iframe) {
36
+ // Already created but not loaded yet
37
+ const prev = iframe.onload;
38
+ iframe.onload = (e) => {
39
+ if (typeof prev === 'function') prev.call(iframe!, e);
40
+ iframeReady = true;
41
+ resolve();
42
+ };
43
+ return;
44
+ }
45
+
46
+ iframe = document.createElement('iframe');
47
+ iframe.src = PUSH_FRAME_URL;
48
+ iframe.style.display = 'none';
49
+ iframe.onload = () => {
50
+ iframeReady = true;
51
+ resolve();
52
+ };
53
+ document.body.appendChild(iframe);
54
+
55
+ window.addEventListener('message', (event) => {
56
+ if (event.origin !== PUSH_FRAME_ORIGIN) return;
57
+ const data = event.data as Record<string, unknown>;
58
+ const func = data.func as string;
59
+ const idx = pendingResolvers.findIndex((r) => r.func === func);
60
+ if (idx >= 0) {
61
+ pendingResolvers[idx]!.resolve(data);
62
+ pendingResolvers.splice(idx, 1);
63
+ }
64
+ });
65
+ });
66
+ }
67
+
68
+ function postAndWait(
69
+ msg: Record<string, unknown>,
70
+ expectFunc: string,
71
+ ): Promise<Record<string, unknown>> {
72
+ return new Promise((resolve) => {
73
+ pendingResolvers.push({ func: expectFunc, resolve });
74
+ iframe!.contentWindow!.postMessage(msg, PUSH_FRAME_ORIGIN);
75
+ });
76
+ }
77
+
78
+ /**
79
+ * Initialize push — loads the iframe and checks registration status.
80
+ * Returns { registered: boolean }.
81
+ */
82
+ export async function initPush(): Promise<{ registered: boolean }> {
83
+ const token = getToken();
84
+ if (!token) return { registered: false };
85
+
86
+ await ensureIframe();
87
+ const result = await postAndWait(
88
+ { func: 'pushInit', projectToken: token },
89
+ 'pushStatus',
90
+ );
91
+ return { registered: result.registered as boolean };
92
+ }
93
+
94
+ /**
95
+ * Request push notification permission and register.
96
+ * The browser will show the notification permission prompt.
97
+ * Resolves when registration is complete.
98
+ */
99
+ export async function requestPushPermission(): Promise<{
100
+ success: boolean;
101
+ error?: string;
102
+ }> {
103
+ const token = getToken();
104
+ if (!token) return { success: false, error: 'no_token' };
105
+
106
+ await ensureIframe();
107
+ const result = await Promise.race([
108
+ postAndWait(
109
+ { func: 'pushRequestPermission', projectToken: token },
110
+ 'pushRegistered',
111
+ ).then(() => ({ success: true })),
112
+ postAndWait(
113
+ { func: 'pushRequestPermission', projectToken: token },
114
+ 'pushError',
115
+ ).then((data) => ({
116
+ success: false,
117
+ error: data.error as string,
118
+ })),
119
+ ]);
120
+ return result;
121
+ }
@@ -1,17 +1,34 @@
1
1
  import {
2
2
  createApp,
3
+ createEmailSender,
3
4
  createUserHelper,
5
+ pgQuery,
4
6
  type AppConfigurator,
5
7
  type RequestHandlers,
6
8
  } from 'ugly-app';
9
+ import type { CronHandlers } from 'ugly-app/shared';
7
10
  import { dbDefaults } from 'ugly-app/shared';
8
11
  import { requests } from '../shared/api';
9
12
  import type { Todo, User } from '../shared/collections';
10
13
  import { collections } from '../shared/collections';
14
+ import { cronTasks } from '../shared/cron';
11
15
  import { experiments } from '../shared/experiments';
12
16
  import { pages } from '../shared/pages';
13
17
 
14
18
  const userHelper = createUserHelper<User>(collections.user);
19
+ // eslint-disable-next-line @typescript-eslint/dot-notation
20
+ const email = createEmailSender(process.env['APP_DOMAIN'] ?? 'myapp');
21
+
22
+ const cronHandlers: CronHandlers<typeof cronTasks> = {
23
+ dailyCleanup: async () => {
24
+ const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
25
+ const result = await pgQuery(
26
+ `DELETE FROM docs_todo WHERE (data->>'done')::boolean = true AND (data->'updated')::bigint < $1`,
27
+ [thirtyDaysAgo.getTime()],
28
+ );
29
+ console.log(`[Cron] dailyCleanup: deleted ${result.rowCount} old completed todos`);
30
+ },
31
+ };
15
32
 
16
33
  const app = createApp(
17
34
  { requests },
@@ -37,12 +54,21 @@ const app = createApp(
37
54
  await app.db.deleteDoc('todo', todoId);
38
55
  return { ok: true };
39
56
  },
57
+
58
+ sendTestEmail: async (_userId, { to, subject, html, id }) => {
59
+ await email.send({ to, subject, html, id });
60
+ return { ok: true };
61
+ },
40
62
  } satisfies RequestHandlers<typeof requests>,
41
63
  collections,
42
64
  (configurator: AppConfigurator) => {
43
65
  configurator.setPages({ pages });
44
66
  configurator.setUserHelper(userHelper);
45
67
  configurator.setExperiments(experiments);
68
+ configurator.setCronTasks(cronTasks, cronHandlers);
69
+ configurator.setOnEmail(async (inbound) => {
70
+ console.log('[Email] Received:', { from: inbound.from, id: inbound.id, subject: inbound.subject });
71
+ });
46
72
  configurator.setOnUserCreate(async (userId, info, db) => {
47
73
  // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
48
74
  await userHelper.set(db, { _id: userId, ...dbDefaults(), ...info });
@@ -17,6 +17,18 @@ export const requests = defineRequests({
17
17
  output: z.object({ ok: z.boolean() }),
18
18
  }),
19
19
 
20
+ // Email test — send an email via the app's email sender
21
+ sendTestEmail: authReq({
22
+ input: z.object({
23
+ to: z.string().email(),
24
+ subject: z.string().min(1).max(200),
25
+ html: z.string().min(1),
26
+ id: z.string().max(100).optional(),
27
+ }),
28
+ output: z.object({ ok: z.boolean() }),
29
+ rateLimit: { max: 5, window: 60 },
30
+ }),
31
+
20
32
  // Example: public request — userId is string | null
21
33
  // getPublicData: req({
22
34
  // input: z.object({ id: z.string() }),
@@ -0,0 +1,8 @@
1
+ import { cronTask, defineCronTasks } from 'ugly-app/shared';
2
+
3
+ export const cronTasks = defineCronTasks({
4
+ dailyCleanup: cronTask({
5
+ schedule: '0 3 * * *', // 3 AM UTC daily
6
+ description: 'Delete completed todos older than 30 days',
7
+ }),
8
+ });
@@ -24,6 +24,7 @@ export const pages = definePages({
24
24
  'kagi-test': definePage<{}>({ auth: true }),
25
25
  'audio-test': definePage<{}>({ auth: true }),
26
26
  'upload-test': definePage<{}>({ auth: true }),
27
+ 'email-test': definePage<{}>({ auth: true }),
27
28
  });
28
29
 
29
30
  export type AppPages = typeof pages;