diff --git a/README.md b/README.md index 1a0666be..4a2191fd 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ This webmail client is designed to work seamlessly with [**Stalwart Mail Server* ### Security & Privacy - External content blocked by default +- Trusted senders list for automatic image loading - HTML sanitization with DOMPurify - SPF/DKIM/DMARC status indicators - No password storage (session-based auth) @@ -92,12 +93,14 @@ Edit `.env.local` with your settings: ```env # App name displayed in the UI -NEXT_PUBLIC_APP_NAME=My Webmail +APP_NAME=My Webmail -# Your JMAP server URL -NEXT_PUBLIC_JMAP_SERVER_URL=https://mail.example.com +# Your JMAP server URL (required) +JMAP_SERVER_URL=https://mail.example.com ``` +**Note:** These are runtime environment variables, read at request time. This enables Docker deployments to be configured without rebuilding the image. Legacy `NEXT_PUBLIC_*` variables are still supported as fallbacks. + ### Development ```bash diff --git a/ROADMAP.md b/ROADMAP.md index 49e098f9..579a173c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -16,6 +16,7 @@ This document tracks the development status and planned features for JMAP Webmai - [x] Username autocomplete with history - [x] Logout functionality - [x] Authentication error handling +- [x] JMAP identities for sender address ### JMAP Server Connection - [x] Session establishment and keep-alive @@ -76,6 +77,10 @@ This document tracks the development status and planned features for JMAP Webmai - [x] External content blocked by default - [x] HTML sanitization with DOMPurify - [x] User control for loading external content +- [x] Trusted senders list for automatic image loading + +### Deployment +- [x] Runtime environment variables (Docker-friendly configuration) ## Planned Features diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx index baa5b812..58eb20d3 100644 --- a/app/[locale]/login/page.tsx +++ b/app/[locale]/login/page.tsx @@ -6,6 +6,7 @@ import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { useAuthStore } from "@/stores/auth-store"; +import { useConfig } from "@/hooks/use-config"; import { Mail, AlertCircle, Loader2, X } from "lucide-react"; export default function LoginPage() { @@ -13,9 +14,7 @@ export default function LoginPage() { const params = useParams(); const t = useTranslations("login"); const { login, isLoading, error, clearError, isAuthenticated } = useAuthStore(); - - const serverUrl = process.env.NEXT_PUBLIC_JMAP_SERVER_URL; - const appName = process.env.NEXT_PUBLIC_APP_NAME || 'Webmail'; + const { appName, jmapServerUrl: serverUrl, isLoading: configLoading, error: configError } = useConfig(); // All hooks must be called unconditionally at the top const [formData, setFormData] = useState({ @@ -100,6 +99,35 @@ export default function LoginPage() { return () => document.removeEventListener("mousedown", handleClickOutside); }, [serverUrl]); + // Show loading state while config is being fetched + if (configLoading) { + return ( +
+
+ + {t("loading")} +
+
+ ); + } + + // Show error if config fetch failed + if (configError) { + return ( +
+
+
+ +
+

{t("config_error.title")}

+

+ {t("config_error.fetch_failed")} +

+
+
+ ); + } + // Show error if JMAP server URL is not configured if (!serverUrl) { return ( @@ -108,9 +136,9 @@ export default function LoginPage() {
-

Configuration Error

+

{t("config_error.title")}

- NEXT_PUBLIC_JMAP_SERVER_URL environment variable is not set. + {t("config_error.server_not_configured")}

@@ -268,7 +296,7 @@ export default function LoginPage() { type="button" onClick={(e) => removeUsername(username, e)} className="p-1 hover:bg-background rounded transition-colors" - title="Remove from history" + title={t("remove_from_history")} > diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index f585000f..c836dff9 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -353,11 +353,13 @@ export default function Home() { subject: string; body: string; draftId?: string; + fromEmail?: string; + identityId?: string; }) => { if (!client) return; try { - await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.draftId); + await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.draftId, data.fromEmail, data.identityId); setShowComposer(false); } catch (error) { console.error("Failed to send email:", error); @@ -810,7 +812,7 @@ export default function Home() { {showComposer && (
void; onClose?: () => void; onDiscardDraft?: (draftId: string) => void; @@ -97,8 +99,9 @@ export function EmailComposer({ const lastSavedDataRef = useRef(""); const [attachments, setAttachments] = useState>([]); const fileInputRef = useRef(null); + const [selectedIdentityId, setSelectedIdentityId] = useState(null); - const { client } = useAuthStore(); + const { client, identities, primaryIdentity } = useAuthStore(); // Handle file selection const handleFileSelect = async (event: React.ChangeEvent) => { @@ -255,6 +258,11 @@ export function EmailComposer({ } } + // Get the selected identity or primary identity + const currentIdentity = selectedIdentityId + ? identities.find(id => id.id === selectedIdentityId) + : primaryIdentity; + onSend?.({ to: toAddresses, cc: ccAddresses, @@ -262,6 +270,8 @@ export function EmailComposer({ subject, body, draftId: finalDraftId || undefined, + fromEmail: currentIdentity?.email, + identityId: currentIdentity?.id, }); // Reset form @@ -301,7 +311,7 @@ export function EmailComposer({
-

New Message

+

{t('new_message')}

{saveStatus === 'saving' && (
@@ -328,8 +338,32 @@ export function EmailComposer({
+ {/* From field - show dropdown if multiple identities, otherwise display email */}
- To: + {t('from')}: + {identities.length > 1 ? ( + + ) : ( + + {primaryIdentity?.name + ? `${primaryIdentity.name} <${primaryIdentity.email}>` + : primaryIdentity?.email || ''} + + )} +
+ +
+ {t('to')}:
-
+