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.
- package/dist/cli/version.d.ts +1 -1
- package/dist/cli/version.js +1 -1
- package/package.json +1 -1
- package/src/cli/version.ts +1 -1
- package/templates/client/allPages.ts +1 -0
- package/templates/client/pages/EmailTestPage.tsx +150 -0
- package/templates/client/push.ts +121 -0
- package/templates/server/index.ts +26 -0
- package/templates/shared/api.ts +12 -0
- package/templates/shared/cron.ts +8 -0
- package/templates/shared/pages.ts +1 -0
package/dist/cli/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const CLI_VERSION = "0.1.
|
|
1
|
+
export declare const CLI_VERSION = "0.1.176";
|
|
2
2
|
//# sourceMappingURL=version.d.ts.map
|
package/dist/cli/version.js
CHANGED
package/package.json
CHANGED
package/src/cli/version.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Auto-generated by prebuild — do not edit manually
|
|
2
|
-
export const CLI_VERSION = "0.1.
|
|
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 });
|
package/templates/shared/api.ts
CHANGED
|
@@ -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() }),
|
|
@@ -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;
|