feat(rinbox): add email forwarding prompt banner and fix auth token reading

Replace broken encryptid-token localStorage reads with getAccessToken/getUsername
from rspace-header. Add forwarding status check against EncryptID API with
enable/disable/dismiss banner on mailboxes view.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-20 11:23:01 -07:00
parent 92df7d332d
commit 1c93e3bb67
1 changed files with 197 additions and 9 deletions

View File

@ -10,6 +10,7 @@ import { mailboxSchema, type MailboxDoc } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document"; import type { DocumentId } from "../../../shared/local-first/document";
import { TourEngine } from "../../../shared/tour-engine"; import { TourEngine } from "../../../shared/tour-engine";
import { ViewHistory } from "../../../shared/view-history.js"; import { ViewHistory } from "../../../shared/view-history.js";
import { getAccessToken, getUsername } from "../../../lib/rspace-header";
type ComposeMode = 'new' | 'reply' | 'reply-all' | 'forward'; type ComposeMode = 'new' | 'reply' | 'reply-all' | 'forward';
@ -35,6 +36,12 @@ class FolkInboxClient extends HTMLElement {
private _usernameCache = new Map<string, string>(); private _usernameCache = new Map<string, string>();
private _currentUsername: string | null = null; private _currentUsername: string | null = null;
private _history = new ViewHistory<"mailboxes" | "threads" | "thread" | "approvals" | "personal" | "agents">("mailboxes"); private _history = new ViewHistory<"mailboxes" | "threads" | "thread" | "approvals" | "personal" | "agents">("mailboxes");
private _fwdStatus: 'loading' | 'unavailable' | 'no-email' | 'ready' | 'enabled' | 'error' = 'loading';
private _fwdAddress = '';
private _fwdTarget = '';
private _fwdDismissed = false;
private _fwdBusy = false;
private _fwdError = '';
private demoApprovals: any[] = [ private demoApprovals: any[] = [
{ {
id: "a1", subject: "Re: Q1 Budget approval for rSpace infrastructure", status: "PENDING", id: "a1", subject: "Re: Q1 Budget approval for rSpace infrastructure", status: "PENDING",
@ -106,6 +113,7 @@ class FolkInboxClient extends HTMLElement {
connectedCallback() { connectedCallback() {
this.space = this.getAttribute("space") || "demo"; this.space = this.getAttribute("space") || "demo";
this._loadUsername(); this._loadUsername();
this._checkForwardStatus();
if (this.space === "demo") { this.loadDemoData(); } if (this.space === "demo") { this.loadDemoData(); }
else { this.subscribeOffline(); this.loadMailboxes(); } else { this.subscribeOffline(); this.loadMailboxes(); }
// Auto-start tour on first visit // Auto-start tour on first visit
@ -119,14 +127,9 @@ class FolkInboxClient extends HTMLElement {
this._offlineUnsubs = []; this._offlineUnsubs = [];
} }
/** Extract username from EncryptID JWT in localStorage */ /** Extract username from EncryptID session */
private _loadUsername() { private _loadUsername() {
try { this._currentUsername = getUsername();
const token = localStorage.getItem('encryptid-token');
if (!token) return;
const payload = JSON.parse(atob(token.split('.')[1]));
if (payload.username) this._currentUsername = payload.username;
} catch { /* no token or invalid */ }
} }
/** Resolve a DID to a display name */ /** Resolve a DID to a display name */
@ -253,7 +256,7 @@ class FolkInboxClient extends HTMLElement {
} }
try { try {
const base = window.location.pathname.replace(/\/$/, ""); const base = window.location.pathname.replace(/\/$/, "");
const token = localStorage.getItem('encryptid-token'); const token = getAccessToken();
const resp = await fetch(`${base}/api/personal-inboxes`, { const resp = await fetch(`${base}/api/personal-inboxes`, {
headers: token ? { Authorization: `Bearer ${token}` } : {}, headers: token ? { Authorization: `Bearer ${token}` } : {},
}); });
@ -286,11 +289,162 @@ class FolkInboxClient extends HTMLElement {
private getAuthHeaders(): Record<string, string> { private getAuthHeaders(): Record<string, string> {
const headers: Record<string, string> = { "Content-Type": "application/json" }; const headers: Record<string, string> = { "Content-Type": "application/json" };
const token = localStorage.getItem('encryptid-token'); const token = getAccessToken();
if (token) headers["Authorization"] = `Bearer ${token}`; if (token) headers["Authorization"] = `Bearer ${token}`;
return headers; return headers;
} }
private async _checkForwardStatus() {
if (sessionStorage.getItem('rinbox_fwd_dismissed')) {
this._fwdDismissed = true;
this._fwdStatus = 'unavailable';
return;
}
const token = getAccessToken();
if (!token) { this._fwdStatus = 'unavailable'; return; }
try {
const resp = await fetch('https://auth.rspace.online/api/account/email-forward', {
headers: { Authorization: `Bearer ${token}` },
});
if (!resp.ok) { this._fwdStatus = 'unavailable'; return; }
const data = await resp.json();
if (data.enabled) {
this._fwdStatus = 'enabled';
this._fwdAddress = data.address || '';
this._fwdTarget = data.target || '';
} else if (data.available && data.target) {
this._fwdStatus = 'ready';
this._fwdAddress = data.address || '';
this._fwdTarget = data.target || '';
} else if (!data.target) {
this._fwdStatus = 'no-email';
this._fwdAddress = data.address || '';
} else {
this._fwdStatus = 'unavailable';
}
} catch {
this._fwdStatus = 'unavailable';
}
this.render();
}
private async _enableForwarding() {
if (this._fwdBusy) return;
this._fwdBusy = true;
this._fwdError = '';
this.render();
const token = getAccessToken();
try {
const resp = await fetch('https://auth.rspace.online/api/account/email-forward/enable', {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
});
if (resp.ok) {
this._fwdStatus = 'enabled';
} else {
const err = await resp.json().catch(() => ({}));
this._fwdError = (err as any).error || 'Failed to enable forwarding';
this._fwdStatus = 'error';
}
} catch {
this._fwdError = 'Network error — try again';
this._fwdStatus = 'error';
}
this._fwdBusy = false;
this.render();
}
private async _disableForwarding() {
if (this._fwdBusy) return;
this._fwdBusy = true;
this.render();
const token = getAccessToken();
try {
const resp = await fetch('https://auth.rspace.online/api/account/email-forward/disable', {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
});
if (resp.ok) {
this._fwdStatus = 'ready';
}
} catch { /* ignore */ }
this._fwdBusy = false;
this.render();
}
private _dismissForwardBanner() {
this._fwdDismissed = true;
sessionStorage.setItem('rinbox_fwd_dismissed', '1');
this.render();
}
private renderForwardBanner(): string {
if (this._fwdDismissed || this._fwdStatus === 'loading' || this._fwdStatus === 'unavailable') return '';
if (this._fwdStatus === 'ready') {
return `
<div class="fwd-banner fwd-ready">
<div class="fwd-banner-content">
<div class="fwd-banner-title">Forward rInbox to your email</div>
<div class="fwd-banner-route">
<code>${this.escapeHtml(this._fwdAddress)}</code>
<span class="fwd-arrow">&rarr;</span>
<code>${this.escapeHtml(this._fwdTarget)}</code>
</div>
</div>
<div class="fwd-banner-actions">
<button class="fwd-btn-enable" data-action="fwd-enable" ${this._fwdBusy ? 'disabled' : ''}>${this._fwdBusy ? 'Enabling...' : 'Enable Forwarding'}</button>
<button class="fwd-btn-dismiss" data-action="fwd-dismiss">Dismiss</button>
</div>
</div>`;
}
if (this._fwdStatus === 'enabled') {
return `
<div class="fwd-banner fwd-enabled">
<div class="fwd-banner-content">
<div class="fwd-banner-title">Forwarding active</div>
<div class="fwd-banner-route">
<code>${this.escapeHtml(this._fwdAddress)}</code>
<span class="fwd-arrow">&rarr;</span>
<code>${this.escapeHtml(this._fwdTarget)}</code>
</div>
</div>
<div class="fwd-banner-actions">
<button class="fwd-btn-disable" data-action="fwd-disable" ${this._fwdBusy ? 'disabled' : ''}>${this._fwdBusy ? 'Disabling...' : 'Disable'}</button>
</div>
</div>`;
}
if (this._fwdStatus === 'no-email') {
return `
<div class="fwd-banner fwd-no-email">
<div class="fwd-banner-content">
<div class="fwd-banner-title">Forward rInbox to your email</div>
<div class="fwd-banner-desc">Add a verified email to your profile to enable forwarding.</div>
</div>
<div class="fwd-banner-actions">
<a class="fwd-btn-profile" href="https://auth.rspace.online/profile" target="_blank" rel="noopener">Go to Profile</a>
<button class="fwd-btn-dismiss" data-action="fwd-dismiss">Dismiss</button>
</div>
</div>`;
}
if (this._fwdStatus === 'error') {
return `
<div class="fwd-banner fwd-error">
<div class="fwd-banner-content">
<div class="fwd-banner-title">${this.escapeHtml(this._fwdError)}</div>
</div>
<div class="fwd-banner-actions">
<button class="fwd-btn-dismiss" data-action="fwd-dismiss">Dismiss</button>
</div>
</div>`;
}
return '';
}
private timeAgo(dateStr: string): string { private timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime(); const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000); const mins = Math.floor(diff / 60000);
@ -509,12 +663,40 @@ class FolkInboxClient extends HTMLElement {
.sample-banner { padding: 8px 16px; background: rgba(99,102,241,0.12); border: 1px solid rgba(99,102,241,0.25); border-radius: 8px; color: #a5b4fc; font-size: 13px; text-align: center; margin-bottom: 12px; } .sample-banner { padding: 8px 16px; background: rgba(99,102,241,0.12); border: 1px solid rgba(99,102,241,0.25); border-radius: 8px; color: #a5b4fc; font-size: 13px; text-align: center; margin-bottom: 12px; }
/* Forwarding banner */
.fwd-banner { display: flex; align-items: center; gap: 1rem; padding: 0.75rem 1rem; border-radius: 10px; margin-bottom: 12px; border: 1px solid; }
.fwd-banner.fwd-ready { background: rgba(99,102,241,0.08); border-color: rgba(99,102,241,0.25); }
.fwd-banner.fwd-enabled { background: rgba(34,197,94,0.08); border-color: rgba(34,197,94,0.25); }
.fwd-banner.fwd-no-email { background: rgba(251,191,36,0.08); border-color: rgba(251,191,36,0.25); }
.fwd-banner.fwd-error { background: rgba(239,68,68,0.08); border-color: rgba(239,68,68,0.25); }
.fwd-banner-content { flex: 1; min-width: 0; }
.fwd-banner-title { font-size: 0.85rem; font-weight: 600; color: var(--rs-text-primary); margin-bottom: 2px; }
.fwd-banner-desc { font-size: 0.8rem; color: var(--rs-text-secondary); }
.fwd-banner-route { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
.fwd-banner-route code { font-size: 0.75rem; color: #818cf8; background: rgba(99,102,241,0.1); padding: 2px 6px; border-radius: 4px; }
.fwd-enabled .fwd-banner-route code { color: #4ade80; background: rgba(34,197,94,0.1); }
.fwd-arrow { color: var(--rs-text-muted); font-size: 0.8rem; }
.fwd-banner-actions { display: flex; gap: 0.5rem; align-items: center; flex-shrink: 0; }
.fwd-btn-enable { padding: 6px 14px; border-radius: 6px; border: none; background: linear-gradient(135deg, #6366f1, #0891b2); color: white; cursor: pointer; font-size: 0.8rem; font-weight: 600; transition: opacity 0.15s; }
.fwd-btn-enable:hover { opacity: 0.9; }
.fwd-btn-enable:disabled { opacity: 0.5; cursor: not-allowed; }
.fwd-btn-disable { padding: 6px 12px; border-radius: 6px; border: 1px solid rgba(34,197,94,0.3); background: transparent; color: #4ade80; cursor: pointer; font-size: 0.75rem; transition: all 0.15s; }
.fwd-btn-disable:hover { border-color: rgba(34,197,94,0.6); background: rgba(34,197,94,0.1); }
.fwd-btn-disable:disabled { opacity: 0.5; cursor: not-allowed; }
.fwd-btn-dismiss { padding: 6px 10px; border-radius: 6px; border: none; background: transparent; color: var(--rs-text-muted); cursor: pointer; font-size: 0.75rem; transition: color 0.15s; }
.fwd-btn-dismiss:hover { color: var(--rs-text-secondary); }
.fwd-btn-profile { padding: 6px 14px; border-radius: 6px; border: 1px solid rgba(251,191,36,0.3); background: rgba(251,191,36,0.1); color: #fbbf24; text-decoration: none; font-size: 0.8rem; font-weight: 500; transition: all 0.15s; }
.fwd-btn-profile:hover { border-color: rgba(251,191,36,0.5); background: rgba(251,191,36,0.15); }
.fwd-error .fwd-banner-title { color: #f87171; }
@media (max-width: 768px) { @media (max-width: 768px) {
.mailbox-grid { grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); } .mailbox-grid { grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); }
.help-grid { grid-template-columns: 1fr; } .help-grid { grid-template-columns: 1fr; }
.thread-row { flex-wrap: wrap; gap: 4px; } .thread-row { flex-wrap: wrap; gap: 4px; }
} }
@media (max-width: 480px) { @media (max-width: 480px) {
.fwd-banner { flex-direction: column; align-items: stretch; gap: 0.5rem; }
.fwd-banner-actions { justify-content: flex-end; }
.rapp-nav { gap: 0.25rem; } .rapp-nav { gap: 0.25rem; }
.nav-btn { padding: 4px 8px; font-size: 12px; } .nav-btn { padding: 4px 8px; font-size: 12px; }
.inbox-header { padding: 0.625rem 0.75rem; gap: 0.5rem; } .inbox-header { padding: 0.625rem 0.75rem; gap: 0.5rem; }
@ -533,6 +715,7 @@ class FolkInboxClient extends HTMLElement {
<div class="container"> <div class="container">
${this.renderNav()} ${this.renderNav()}
${this.showingSampleData ? '<div class="sample-banner">Showing sample data — create a mailbox to get started</div>' : ''} ${this.showingSampleData ? '<div class="sample-banner">Showing sample data — create a mailbox to get started</div>' : ''}
${this.view === 'mailboxes' ? this.renderForwardBanner() : ''}
${this.renderView()} ${this.renderView()}
${this.helpOpen ? this.renderHelp() : ""} ${this.helpOpen ? this.renderHelp() : ""}
</div> </div>
@ -1154,6 +1337,11 @@ class FolkInboxClient extends HTMLElement {
helpClose.addEventListener("click", () => { this.helpOpen = false; this.render(); }); helpClose.addEventListener("click", () => { this.helpOpen = false; this.render(); });
} }
// Forwarding banner actions
this.shadow.querySelector("[data-action='fwd-enable']")?.addEventListener("click", () => this._enableForwarding());
this.shadow.querySelector("[data-action='fwd-disable']")?.addEventListener("click", () => this._disableForwarding());
this.shadow.querySelector("[data-action='fwd-dismiss']")?.addEventListener("click", () => this._dismissForwardBanner());
// Mailbox click // Mailbox click
this.shadow.querySelectorAll("[data-mailbox]").forEach((card) => { this.shadow.querySelectorAll("[data-mailbox]").forEach((card) => {
card.addEventListener("click", () => { card.addEventListener("click", () => {