Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 120 additions & 3 deletions apps/web/src/components/AccountView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,56 @@
let apiKeyCopied = $state(false);
let plans = $state<PlansSnapshot | null>(null);
let selectedPlan = $state<PlanType>("annual");
let webhookUrl = $state("");
let webhookSecret = $state("");
let webhookInput = $state("");
let webhookSaving = $state(false);
let webhookError = $state("");
let webhookSecretCopied = $state(false);

async function loadWebhook() {
if (!user.apiKey || !user.isPremium) return;
try {
const cfg = await api.getWebhook(user.apiKey);
webhookUrl = cfg.url || "";
webhookSecret = cfg.secret || "";
webhookInput = webhookUrl;
} catch {
// silent
}
}

async function saveWebhook() {
if (!user.apiKey) return;
if (!webhookInput.startsWith("https://")) {
webhookError = t("webhookInvalidUrl");
return;
}
webhookSaving = true;
webhookError = "";
try {
const cfg = await api.setWebhook(webhookInput.trim(), user.apiKey);
webhookUrl = cfg.url;
webhookSecret = cfg.secret;
} catch (e: any) {
webhookError = e?.message || t("genericError");
} finally {
webhookSaving = false;
}
}

async function removeWebhook() {
if (!user.apiKey) return;
webhookSaving = true;
try {
await api.deleteWebhook(user.apiKey);
webhookUrl = "";
webhookSecret = "";
webhookInput = "";
} finally {
webhookSaving = false;
}
}

async function loadPlanData() {
if (!user.info?.user_id) return;
Expand Down Expand Up @@ -137,6 +187,7 @@
initUser().then(() => {
loadInboxes();
if (!user.isPremium) loadPlanData();
if (user.isPremium) loadWebhook();
});
document.addEventListener("visibilitychange", onVisibilityChange);
return () => document.removeEventListener("visibilitychange", onVisibilityChange);
Expand Down Expand Up @@ -227,15 +278,15 @@
{#if !user.isPremium}
{#if plans?.monthly && plans?.annual}
<div class="mb-3">
<div class="text-[10px] text-ghost font-mono tracking-widest mb-2">{t("chooseBilling")}</div>
<div class="text-[10px] text-muted font-mono tracking-widest mb-2">{t("chooseBilling")}</div>
<div class="grid grid-cols-2 gap-2">
<button
type="button"
onclick={() => (selectedPlan = "monthly")}
class="relative text-left p-3 border rounded-sm transition-all duration-200
{selectedPlan === 'monthly' ? 'border-neon/60 bg-neon/5' : 'border-ghost/20 hover:border-ghost/40'}"
>
<div class="text-[10px] font-mono tracking-widest text-ghost">{t("monthly")}</div>
<div class="text-[10px] font-mono tracking-widest text-muted">{t("monthly")}</div>
<div class="font-display text-white text-base mt-1">
{plans.monthly.formattedPrice}<span class="text-xs text-muted">{t("perMonth")}</span>
</div>
Expand All @@ -251,7 +302,7 @@
{t("saveAnnual", { percent: plans.annualSavingsPercent })}
</span>
{/if}
<div class="text-[10px] font-mono tracking-widest text-ghost">{t("annual")}</div>
<div class="text-[10px] font-mono tracking-widest text-muted">{t("annual")}</div>
<div class="font-display text-white text-base mt-1">
{plans.annual.formattedPrice}<span class="text-xs text-muted">{t("perYear")}</span>
</div>
Expand Down Expand Up @@ -381,6 +432,72 @@
</a>
</div>

<!-- Webhook -->
<div class="bg-abyss/80 glow-border rounded-sm p-5">
<div class="flex items-center justify-between mb-2">
<h3 class="text-white text-sm font-display tracking-wider">{t("webhook")}</h3>
{#if webhookUrl}
<span class="bg-pulse/15 text-pulse text-[9px] tracking-widest px-1.5 py-0.5 font-mono">
{t("webhookActive")}
</span>
{/if}
</div>
<p class="text-muted text-[10px] font-mono mb-3">{t("webhookDescription")}</p>

<label class="block text-[10px] font-mono tracking-widest text-muted mb-1">{t("webhookUrl")}</label>
<div class="flex gap-2 mb-3">
<input
type="url"
bind:value={webhookInput}
placeholder={t("webhookUrlPlaceholder")}
class="flex-1 bg-slab/50 border border-ghost/20 text-white text-xs font-mono px-3 py-2 rounded-sm
focus:border-neon/40 focus:outline-none placeholder:text-ghost/40"
/>
<button
onclick={saveWebhook}
disabled={webhookSaving || !webhookInput}
class="px-3 py-2 text-[10px] font-mono tracking-wider border border-neon/40 text-neon
hover:bg-neon/10 transition-all duration-300 disabled:opacity-40"
>
{webhookSaving ? "..." : t("webhookSave")}
</button>
{#if webhookUrl}
<button
onclick={removeWebhook}
disabled={webhookSaving}
class="px-3 py-2 text-[10px] font-mono tracking-wider border border-ember/30 text-ember
hover:bg-ember/10 transition-all duration-300 disabled:opacity-40"
>
{t("webhookRemove")}
</button>
{/if}
</div>
{#if webhookError}
<div class="text-ember text-[11px] font-mono mb-2">{webhookError}</div>
{/if}

{#if webhookSecret}
<label class="block text-[10px] font-mono tracking-widest text-muted mb-1">{t("webhookSecret")}</label>
<div class="flex items-center gap-2 mb-2">
<code class="flex-1 bg-slab/50 border border-ghost/20 text-neon text-[11px] font-mono px-3 py-2 rounded-sm truncate">
{webhookSecret}
</code>
<button
onclick={async () => {
await navigator.clipboard.writeText(webhookSecret);
webhookSecretCopied = true;
setTimeout(() => (webhookSecretCopied = false), 2000);
}}
class="px-3 py-2 text-[10px] font-mono border border-ghost/30 transition-all
{webhookSecretCopied ? 'text-pulse border-pulse/40' : 'text-ghost hover:text-neon hover:border-neon/40'}"
>
{webhookSecretCopied ? "✓" : t("copy")}
</button>
</div>
<p class="text-muted text-[10px] font-mono">{t("webhookSecretHelp")}</p>
{/if}
</div>

<!-- Active Inboxes -->
{#if activeInboxes.length > 0}
<div class="bg-abyss/80 glow-border rounded-sm p-5">
Expand Down
36 changes: 33 additions & 3 deletions apps/web/src/components/InboxView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,34 @@
}
await createNewInbox();
} else {
// Existing address — load saved token, real expiry comes from first poll
inboxToken = loadToken(inboxAddress);
// Existing address — prefer the saved token from creation. If we don't
// have one, fall back to the logged-in user's API key (the backend
// checks ownership). If neither exists, this inbox isn't ours and we
// bounce to home so visitors can create their own.
const saved = loadToken(inboxAddress);
let candidate = "";
if (saved) {
candidate = saved;
} else if (user.apiKey) {
candidate = user.apiKey;
} else {
window.location.replace(localizePath("/"));
return;
}

// Probe the API once with the candidate before rendering the inbox UI.
// A 401 means we don't actually own this address — redirect home so we
// never show the inbox shell to someone who can't read the messages.
try {
const probe = await api.getInbox(inboxAddress, candidate);
inboxToken = candidate;
messages = probe.messages || [];
messageCount = messages.length;
if (probe.expires_at) expiresAt = probe.expires_at;
} catch {
window.location.replace(localizePath("/"));
return;
}
}
} finally {
isLoading = false;
Expand Down Expand Up @@ -403,7 +429,11 @@
<div class="bg-slab/30 glow-border rounded-sm min-h-[400px]">
{#if selectedMessage}
<div class="p-4 md:p-6">
<MessageDetail message={selectedMessage} />
<MessageDetail
message={selectedMessage}
address={inboxAddress}
token={inboxToken}
/>
</div>
{:else}
<div class="p-3">
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/LandingPage.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -204,8 +204,8 @@
<div class="font-display text-xl text-white mb-1">
$4<span class="text-sm text-muted">{t("perMonth")}</span>
</div>
<div class="flex items-center gap-2 text-[11px] font-mono text-ghost mb-4">
<span>$40<span class="text-muted">{t("perYear")}</span></span>
<div class="flex items-center gap-2 text-[11px] font-mono mb-4">
<span class="text-white/80">$40<span class="text-muted">{t("perYear")}</span></span>
<span class="bg-neon/15 text-neon text-[9px] tracking-widest px-1.5 py-0.5">
{t("saveAnnual", { percent: 17 })}
</span>
Expand Down
76 changes: 67 additions & 9 deletions apps/web/src/components/MessageDetail.svelte
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
<script lang="ts">
import type { MessageDetail as MessageDetailType } from "@ephemask/shared";
import { formatDate } from "@ephemask/shared";
import type { MessageDetail as MessageDetailType, AttachmentMeta } from "@ephemask/shared";
import { formatDate, t, createApiClient } from "@ephemask/shared";
import { onMount } from "svelte";

interface Props {
message: MessageDetailType;
address?: string;
token?: string;
}
let { message }: Props = $props();
let { message, address, token }: Props = $props();

const api = createApiClient(
typeof import.meta !== "undefined"
? import.meta.env.PUBLIC_API_URL || ""
: ""
);

let iframeEl: HTMLIFrameElement | undefined = $state();
let downloading = $state<number | null>(null);

function sanitizeHTML(html: string): string {
return html
Expand All @@ -18,6 +27,24 @@
.replace(/javascript:/gi, "");
}

function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

async function handleDownload(att: AttachmentMeta) {
if (!address || !token) return;
downloading = att.index;
try {
await api.downloadAttachment(address, message.message_id, att.index, att.filename, token);
} catch (e) {
console.error("Download failed:", e);
} finally {
downloading = null;
}
}

onMount(() => {
if (iframeEl && message.body_html) {
const doc = iframeEl.contentDocument;
Expand Down Expand Up @@ -59,16 +86,16 @@
<h2 class="text-white text-lg mb-3 break-words">{message.subject}</h2>
<div class="flex flex-col gap-1.5 text-xs font-mono">
<div class="flex items-center gap-2">
<span class="text-ghost w-10">FROM</span>
<span class="text-muted">{message.from}</span>
<span class="text-muted w-10">FROM</span>
<span class="text-white/80">{message.from}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-ghost w-10">DATE</span>
<span class="text-muted">{formatDate(message.received_at)}</span>
<span class="text-muted w-10">DATE</span>
<span class="text-white/80">{formatDate(message.received_at)}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-ghost w-10">ID</span>
<span class="text-ghost truncate">{message.message_id}</span>
<span class="text-muted w-10">ID</span>
<span class="text-muted truncate">{message.message_id}</span>
</div>
</div>
</div>
Expand All @@ -89,4 +116,35 @@
<pre class="font-mono text-xs text-muted whitespace-pre-wrap break-words leading-relaxed">{message.body_text}</pre>
</div>
{/if}

<!-- Attachments -->
{#if message.attachments && message.attachments.length > 0}
<div class="mt-4">
<div class="text-[10px] font-mono tracking-widest text-muted mb-2">
{t("attachments")} ({message.attachments.length})
</div>
<div class="space-y-1.5">
{#each message.attachments as att}
<div class="flex items-center justify-between gap-3 bg-slab/40 border border-ghost/20 rounded-sm px-3 py-2">
<div class="flex items-center gap-2 min-w-0 flex-1">
<svg class="w-4 h-4 text-neon flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
</svg>
<span class="text-xs text-white/80 font-mono truncate">{att.filename}</span>
<span class="text-[10px] text-muted font-mono flex-shrink-0">{formatSize(att.size)}</span>
</div>
{#if address && token}
<button
onclick={() => handleDownload(att)}
disabled={downloading === att.index}
class="text-[10px] font-mono tracking-wider text-neon hover:bg-neon/10 px-2 py-1 border border-neon/30 transition-colors disabled:opacity-40"
>
{downloading === att.index ? "..." : t("download")}
</button>
{/if}
</div>
{/each}
</div>
</div>
{/if}
</div>
45 changes: 44 additions & 1 deletion apps/web/src/pages/docs.astro
Original file line number Diff line number Diff line change
Expand Up @@ -195,8 +195,51 @@ curl -H "Authorization: Bearer INBOX_TOKEN" \\

# Read a message
curl -H "Authorization: Bearer INBOX_TOKEN" \\
https://api.ephemask.com/inbox/x7k2m9p4@ephemask.com/messages/abc123`}</pre>
https://api.ephemask.com/inbox/x7k2m9p4@ephemask.com/messages/abc123

# Download an attachment
curl -H "Authorization: Bearer INBOX_TOKEN" \\
https://api.ephemask.com/inbox/x7k2m9p4@ephemask.com/messages/abc123/attachments/0 \\
-o file.pdf`}</pre>
</div>
</section>

<!-- Webhook -->
<section>
<h2 class="text-white text-sm font-display tracking-wider mb-3">Webhooks</h2>
<p class="text-muted mb-3">
Configure a webhook URL on your account page and receive a POST request for every email that lands in any of your inboxes.
</p>
<p class="text-muted mb-3">Headers sent with each delivery:</p>
<div class="bg-abyss/80 glow-border rounded-sm p-4 mb-3">
<pre class="text-muted overflow-x-auto">{`Content-Type: application/json
X-Ephemask-Event: email.received
X-Ephemask-Signature: sha256=<hex_hmac_of_body>
User-Agent: Ephemask-Webhook/1.0`}</pre>
</div>
<p class="text-muted mb-3">Payload:</p>
<div class="bg-abyss/80 glow-border rounded-sm p-4 mb-3">
<pre class="text-muted overflow-x-auto">{`{
"event": "email.received",
"inbox_address": "x7k2m9p4@ephemask.com",
"message_id": "abc123",
"from": "sender@example.com",
"subject": "Hello",
"received_at": 1714003200,
"attachment_count": 1
}`}</pre>
</div>
<p class="text-muted mb-3">
Compute <code class="text-neon">HMAC-SHA256(body, your_secret)</code> and compare to <code class="text-neon">X-Ephemask-Signature</code> to verify the request came from Ephemask. The body itself never includes the email content — fetch the message via the API using the <code class="text-neon">message_id</code>.
</p>
<p class="text-muted mb-3">Quick verify with bash + openssl:</p>
<div class="bg-abyss/80 glow-border rounded-sm p-4 mb-3">
<pre class="text-muted overflow-x-auto">{`# Use printf '%s' (not echo -n) so no CR/LF is appended on Windows shells
printf '%s' '<raw_body_bytes>' | openssl dgst -sha256 -hmac '<your_secret>'`}</pre>
</div>
<p class="text-muted">
Delivery is best-effort with a 5s timeout and no retry. Your inbox always has the message regardless of delivery success.
</p>
</section>

</div>
Expand Down
Loading
Loading