Skip to content

Commit b5855ec

Browse files
author
CloudLobster
committed
feat: BaseMail v2 — multiple basename aliases, expiry tracking, settings API
- Add basename_aliases table for multi-handle support - New /api/settings endpoints: GET/PUT settings, POST/DELETE alias, PUT primary - Auto-migration middleware for new tables + notification_email column - getBasenameExpiry() on-chain lookup via nameExpires() - Insert alias on upgrade with is_primary=1 and expiry - GET /api/register/basenames/:address public endpoint - Email routing: alias handles resolve to wallet's inbox - Dashboard Settings: notification email, basename list with expiry colors, add/remove aliases, switch primary handle - Landing FAQ: basename expiry explanation - Expiry color coding: green >90d, yellow 30-90d, orange 7-30d, red <7d
1 parent 4d39139 commit b5855ec

File tree

8 files changed

+508
-1
lines changed

8 files changed

+508
-1
lines changed

web/src/pages/Dashboard.tsx

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1835,6 +1835,41 @@ function Settings({ auth, setAuth, onUpgrade, upgrading }: { auth: AuthState; se
18351835
const [proError, setProError] = useState('');
18361836
const [showProConfetti, setShowProConfetti] = useState(false);
18371837

1838+
// v2: Notification email, aliases, expiry
1839+
const [notifEmail, setNotifEmail] = useState('');
1840+
const [notifSaving, setNotifSaving] = useState(false);
1841+
const [notifSaved, setNotifSaved] = useState(false);
1842+
const [aliases, setAliases] = useState<{ id: string; handle: string; basename: string; is_primary: number; expiry: number | null }[]>([]);
1843+
const [newAliasInput, setNewAliasInput] = useState('');
1844+
const [aliasAdding, setAliasAdding] = useState(false);
1845+
const [aliasError, setAliasError] = useState('');
1846+
const [aliasMsg, setAliasMsg] = useState('');
1847+
1848+
// Load settings on mount
1849+
useEffect(() => {
1850+
apiFetch('/api/settings', auth.token).then(r => r.json()).then((data: any) => {
1851+
if (data.notification_email) setNotifEmail(data.notification_email);
1852+
if (data.aliases) setAliases(data.aliases);
1853+
}).catch(() => {});
1854+
}, [auth.token]);
1855+
1856+
function getExpiryColor(expiry: number | null): string {
1857+
if (!expiry) return 'text-gray-400';
1858+
const daysLeft = (expiry - Date.now() / 1000) / 86400;
1859+
if (daysLeft < 0) return 'text-red-500';
1860+
if (daysLeft < 7) return 'text-red-400';
1861+
if (daysLeft < 30) return 'text-orange-400';
1862+
if (daysLeft < 90) return 'text-yellow-400';
1863+
return 'text-green-400';
1864+
}
1865+
1866+
function getExpiryText(expiry: number | null): string {
1867+
if (!expiry) return 'Unknown';
1868+
const daysLeft = Math.floor((expiry - Date.now() / 1000) / 86400);
1869+
if (daysLeft < 0) return `Expired ${Math.abs(daysLeft)}d ago`;
1870+
return `${daysLeft}d remaining`;
1871+
}
1872+
18381873
const fullEmail = `${auth.handle}@basemail.ai`;
18391874
const hasBasename = !!auth.basename && !/^0x/i.test(auth.handle!);
18401875
const altEmail = hasBasename ? `${auth.wallet.toLowerCase()}@basemail.ai` : null;
@@ -2064,6 +2099,148 @@ function Settings({ auth, setAuth, onUpgrade, upgrading }: { auth: AuthState; se
20642099
</div>
20652100
</div>
20662101

2102+
{/* Notification Email */}
2103+
<div className="bg-base-gray rounded-xl p-6 border border-gray-800">
2104+
<h3 className="font-bold mb-4">Notification Email</h3>
2105+
<p className="text-gray-400 text-sm mb-4">
2106+
Where to send expiry reminders and important notifications. Defaults to your BaseMail address.
2107+
</p>
2108+
<div className="flex gap-2">
2109+
<input
2110+
type="email"
2111+
value={notifEmail}
2112+
onChange={(e) => { setNotifEmail(e.target.value); setNotifSaved(false); }}
2113+
placeholder={`${auth.handle}@basemail.ai`}
2114+
className="flex-1 bg-base-dark border border-gray-700 rounded-lg px-4 py-2 text-white font-mono text-sm focus:outline-none focus:border-base-blue"
2115+
/>
2116+
<button
2117+
onClick={async () => {
2118+
setNotifSaving(true);
2119+
try {
2120+
await apiFetch('/api/settings', auth.token, {
2121+
method: 'PUT',
2122+
body: JSON.stringify({ notification_email: notifEmail }),
2123+
});
2124+
setNotifSaved(true);
2125+
} catch {}
2126+
setNotifSaving(false);
2127+
}}
2128+
disabled={notifSaving}
2129+
className="bg-base-blue text-white px-4 py-2 rounded-lg text-sm hover:bg-blue-600 transition disabled:opacity-50"
2130+
>
2131+
{notifSaving ? 'Saving...' : notifSaved ? 'Saved!' : 'Save'}
2132+
</button>
2133+
</div>
2134+
</div>
2135+
2136+
{/* Your Basenames */}
2137+
<div className="bg-base-gray rounded-xl p-6 border border-gray-800">
2138+
<h3 className="font-bold mb-4">Your Basenames</h3>
2139+
{aliases.length > 0 ? (
2140+
<div className="space-y-3 mb-4">
2141+
{aliases.map((a) => (
2142+
<div key={a.handle} className="flex items-center justify-between bg-base-dark rounded-lg p-3 border border-gray-700">
2143+
<div className="flex items-center gap-3">
2144+
<input
2145+
type="radio"
2146+
name="primary-alias"
2147+
checked={a.is_primary === 1}
2148+
onChange={async () => {
2149+
try {
2150+
const res = await apiFetch('/api/settings/primary', auth.token, {
2151+
method: 'PUT',
2152+
body: JSON.stringify({ handle: a.handle }),
2153+
});
2154+
const data = await res.json() as any;
2155+
if (res.ok && data.token) {
2156+
setAuth({ ...auth, token: data.token, handle: data.handle, basename: data.basename });
2157+
// Reload aliases
2158+
const sr = await apiFetch('/api/settings', data.token);
2159+
const sd = await sr.json() as any;
2160+
if (sd.aliases) setAliases(sd.aliases);
2161+
}
2162+
} catch {}
2163+
}}
2164+
className="accent-blue-500"
2165+
/>
2166+
<div>
2167+
<span className="font-mono text-sm text-base-blue">{a.handle}@basemail.ai</span>
2168+
{a.is_primary === 1 && <span className="ml-2 text-xs bg-blue-900/50 text-blue-300 px-2 py-0.5 rounded">Primary</span>}
2169+
<div className={`text-xs mt-0.5 ${getExpiryColor(a.expiry)}`}>
2170+
{a.expiry ? (
2171+
<>
2172+
{getExpiryText(a.expiry)}
2173+
{' · '}
2174+
<a href={`https://www.base.org/names/${a.handle}`} target="_blank" rel="noopener noreferrer" className="text-base-blue hover:underline">Renew</a>
2175+
</>
2176+
) : (
2177+
<span className="text-gray-500">Expiry unknown</span>
2178+
)}
2179+
</div>
2180+
</div>
2181+
</div>
2182+
{a.is_primary !== 1 && (
2183+
<button
2184+
onClick={async () => {
2185+
await apiFetch(`/api/settings/alias/${a.handle}`, auth.token, { method: 'DELETE' });
2186+
setAliases(aliases.filter(x => x.handle !== a.handle));
2187+
}}
2188+
className="text-gray-500 hover:text-red-400 text-xs"
2189+
>
2190+
Remove
2191+
</button>
2192+
)}
2193+
</div>
2194+
))}
2195+
</div>
2196+
) : (
2197+
<p className="text-gray-500 text-sm mb-4">No basename aliases configured yet.</p>
2198+
)}
2199+
<div className="flex gap-2">
2200+
<div className="flex-1 flex items-center bg-base-dark rounded-lg border border-gray-700 px-2">
2201+
<input
2202+
type="text"
2203+
value={newAliasInput}
2204+
onChange={(e) => { setNewAliasInput(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '')); setAliasError(''); setAliasMsg(''); }}
2205+
placeholder="yourname"
2206+
className="flex-1 bg-transparent py-2 text-white font-mono text-sm focus:outline-none"
2207+
/>
2208+
<span className="text-gray-500 font-mono text-xs">.base.eth</span>
2209+
</div>
2210+
<button
2211+
onClick={async () => {
2212+
if (!newAliasInput.trim()) return;
2213+
setAliasAdding(true);
2214+
setAliasError('');
2215+
setAliasMsg('');
2216+
try {
2217+
const res = await apiFetch('/api/settings/alias', auth.token, {
2218+
method: 'POST',
2219+
body: JSON.stringify({ basename: `${newAliasInput.trim()}.base.eth` }),
2220+
});
2221+
const data = await res.json() as any;
2222+
if (!res.ok) throw new Error(data.error);
2223+
setAliasMsg(`Added ${data.handle}@basemail.ai`);
2224+
setNewAliasInput('');
2225+
// Reload
2226+
const sr = await apiFetch('/api/settings', auth.token);
2227+
const sd = await sr.json() as any;
2228+
if (sd.aliases) setAliases(sd.aliases);
2229+
} catch (e: any) {
2230+
setAliasError(e.message || 'Failed to add alias');
2231+
}
2232+
setAliasAdding(false);
2233+
}}
2234+
disabled={aliasAdding || !newAliasInput.trim()}
2235+
className="bg-base-blue text-white px-4 py-2 rounded-lg text-sm hover:bg-blue-500 transition disabled:opacity-50"
2236+
>
2237+
{aliasAdding ? 'Verifying...' : 'Add'}
2238+
</button>
2239+
</div>
2240+
{aliasError && <p className="text-red-400 text-xs mt-2">{aliasError}</p>}
2241+
{aliasMsg && <p className="text-green-400 text-xs mt-2">{aliasMsg}</p>}
2242+
</div>
2243+
20672244
<div className="bg-base-gray rounded-xl p-6 border border-gray-800">
20682245
<h3 className="font-bold mb-4">API Token</h3>
20692246
<p className="text-gray-400 text-sm mb-4">

web/src/pages/Landing.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,6 +538,10 @@ export default function Landing() {
538538
q="Is there a Pro plan?"
539539
a="BaseMail Pro is a one-time lifetime purchase that unlocks a cleaner email experience, advanced features, and priority support. Available in the Dashboard settings after you register."
540540
/>
541+
<FAQItem
542+
q="What happens if my Basename expires?"
543+
a="Basenames are leased for 1 year. We'll send you reminders before expiry. During the 90-day grace period after expiry, your email continues to work but with a warning. After the grace period, your handle reverts to your wallet address (0x...@basemail.ai) and becomes available for the new Basename owner. Your email history is preserved under your wallet. Automated handle transfer for expired names is coming soon."
544+
/>
541545
</div>
542546
</section>
543547

worker/src/basename-lookup.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,35 @@ export async function hasBasenameNFT(address: Address): Promise<boolean> {
115115
}
116116
}
117117

118+
/**
119+
* Get expiry timestamp for a Basename from on-chain nameExpires().
120+
* Returns unix timestamp or 0 on error.
121+
*/
122+
export async function getBasenameExpiry(name: string): Promise<number> {
123+
try {
124+
const label = name.replace(/\.base\.eth$/, '');
125+
const labelhash = keccak256(toBytes(label));
126+
const tokenId = BigInt(labelhash);
127+
128+
const client = createPublicClient({ chain: base, transport: http(BASE_RPC) });
129+
const expiry = await client.readContract({
130+
abi: [{
131+
inputs: [{ name: 'id', type: 'uint256' }],
132+
name: 'nameExpires',
133+
outputs: [{ name: '', type: 'uint256' }],
134+
stateMutability: 'view',
135+
type: 'function',
136+
}],
137+
address: BASENAME_REGISTRAR,
138+
functionName: 'nameExpires',
139+
args: [tokenId],
140+
});
141+
return Number(expiry);
142+
} catch {
143+
return 0;
144+
}
145+
}
146+
118147
/**
119148
* Extract handle from a Basename.
120149
* "alice.base.eth" → "alice"

worker/src/db/schema.sql

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,23 @@ CREATE TABLE IF NOT EXISTS sender_reputation (
141141

142142
CREATE UNIQUE INDEX IF NOT EXISTS idx_reputation_pair ON sender_reputation(sender_handle, recipient_handle);
143143

144+
-- ═══════════════════════════════════════════════════
145+
-- BaseMail v2: Basename Aliases & Multi-Handle
146+
-- ═══════════════════════════════════════════════════
147+
148+
CREATE TABLE IF NOT EXISTS basename_aliases (
149+
id TEXT PRIMARY KEY,
150+
wallet TEXT NOT NULL,
151+
handle TEXT NOT NULL,
152+
basename TEXT NOT NULL,
153+
is_primary INTEGER NOT NULL DEFAULT 0,
154+
expiry INTEGER,
155+
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
156+
FOREIGN KEY (wallet) REFERENCES accounts(wallet)
157+
);
158+
CREATE UNIQUE INDEX IF NOT EXISTS idx_alias_handle ON basename_aliases(handle);
159+
CREATE INDEX IF NOT EXISTS idx_alias_wallet ON basename_aliases(wallet);
160+
144161
-- ── QAF Scores (cached per recipient) ──
145162
CREATE TABLE IF NOT EXISTS qaf_scores (
146163
handle TEXT PRIMARY KEY,

worker/src/email-handler.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,22 @@ export async function handleIncomingEmail(
3939
).bind(handle).first<{ handle: string; webhook_url: string | null }>();
4040
}
4141

42+
// Alias fallback: check basename_aliases table
43+
if (!account) {
44+
try {
45+
const alias = await env.DB.prepare(
46+
'SELECT wallet FROM basename_aliases WHERE handle = ?'
47+
).bind(handle).first<{ wallet: string }>();
48+
if (alias) {
49+
account = await env.DB.prepare(
50+
'SELECT handle, webhook_url FROM accounts WHERE wallet = ?'
51+
).bind(alias.wallet).first<{ handle: string; webhook_url: string | null }>();
52+
}
53+
} catch {
54+
// basename_aliases table may not exist yet
55+
}
56+
}
57+
4258
if (!account) {
4359
// 外部來信不預存,直接拒絕
4460
message.setReject(`Mailbox not found: ${toAddr}`);

worker/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { waitlistRoutes } from './routes/waitlist';
1919
import { statsRoutes } from './routes/stats';
2020
import { keyRoutes } from './routes/keys';
2121
import { attentionRoutes } from './routes/attention';
22+
import { settingsRoutes } from './routes/settings';
2223
import { handleIncomingEmail } from './email-handler';
2324

2425
const app = new Hono<AppBindings>();
@@ -449,6 +450,7 @@ app.route('/api/waitlist', waitlistRoutes);
449450
app.route('/api/stats', statsRoutes);
450451
app.route('/api/keys', keyRoutes);
451452
app.route('/api/attention', attentionRoutes);
453+
app.route('/api/settings', settingsRoutes);
452454

453455
// 匯出 fetch handler (HTTP) 與 email handler (incoming mail)
454456
export default {

worker/src/routes/register.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Hono } from 'hono';
22
import { AppBindings } from '../types';
33
import { authMiddleware, createToken } from '../auth';
4-
import { resolveHandle, basenameToHandle, verifyBasenameOwnership } from '../basename-lookup';
4+
import { resolveHandle, basenameToHandle, verifyBasenameOwnership, getBasenameExpiry, getBasenameForAddress } from '../basename-lookup';
55
import { registerBasename, isBasenameAvailable, getBasenamePrice } from '../basename';
66
import type { Hex, Address } from 'viem';
77
import { formatEther, encodeFunctionData, namehash } from 'viem';
@@ -293,6 +293,18 @@ registerRoutes.put('/upgrade', authMiddleware(), async (c) => {
293293
]);
294294
const migratedCount = batchResults[2]?.meta?.changes || 0;
295295

296+
// Insert into basename_aliases with is_primary=1
297+
if (basenames) {
298+
const aliasId = `alias-${Date.now().toString(36)}-${crypto.randomUUID().slice(0, 8)}`;
299+
let expiry = 0;
300+
try { expiry = await getBasenameExpiry(basenames); } catch {}
301+
await c.env.DB.prepare(
302+
`INSERT INTO basename_aliases (id, wallet, handle, basename, is_primary, expiry)
303+
VALUES (?, ?, ?, ?, 1, ?)
304+
ON CONFLICT(handle) DO UPDATE SET is_primary = 1, expiry = ?`
305+
).bind(aliasId, auth.wallet, newHandle, basenames, expiry || null, expiry || null).run();
306+
}
307+
296308
// 發新 token
297309
const secret = c.env.JWT_SECRET!;
298310
const newToken = await createToken({ wallet: auth.wallet, handle: newHandle }, secret);
@@ -556,6 +568,40 @@ registerRoutes.get('/buy-data/:name', async (c) => {
556568
}
557569
});
558570

571+
/**
572+
* GET /api/register/basenames/:address
573+
* Public endpoint — returns Basenames owned by a wallet (via reverse resolution).
574+
*/
575+
registerRoutes.get('/basenames/:address', async (c) => {
576+
const address = c.req.param('address');
577+
if (!/^0x[a-fA-F0-9]{40}$/i.test(address)) {
578+
return c.json({ error: 'Invalid address' }, 400);
579+
}
580+
581+
const basename = await getBasenameForAddress(address.toLowerCase() as Address);
582+
const basenames: { name: string; handle: string; expiry: number }[] = [];
583+
584+
if (basename) {
585+
const handle = basenameToHandle(basename);
586+
let expiry = 0;
587+
try { expiry = await getBasenameExpiry(basename); } catch {}
588+
basenames.push({ name: basename, handle, expiry });
589+
}
590+
591+
// Also check aliases stored in DB
592+
const aliases = await c.env.DB.prepare(
593+
'SELECT handle, basename, expiry FROM basename_aliases WHERE wallet = ?'
594+
).bind(address.toLowerCase()).all<{ handle: string; basename: string; expiry: number | null }>();
595+
596+
for (const a of (aliases.results || [])) {
597+
if (!basenames.find(b => b.handle === a.handle)) {
598+
basenames.push({ name: a.basename, handle: a.handle, expiry: a.expiry || 0 });
599+
}
600+
}
601+
602+
return c.json({ address: address.toLowerCase(), basenames });
603+
});
604+
559605
function isValidBasename(name: string): boolean {
560606
if (name.length < 3 || name.length > 32) return false;
561607
return /^[a-z0-9][a-z0-9_-]*[a-z0-9]$/.test(name);

0 commit comments

Comments
 (0)