+ {/* From field - show dropdown if multiple identities, otherwise display email */}
+
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx
index 824431d8..59ca6f22 100644
--- a/components/email/email-viewer.tsx
+++ b/components/email/email-viewer.tsx
@@ -134,6 +134,8 @@ export function EmailViewer({
const t = useTranslations('email_viewer');
const tNotifications = useTranslations('notifications');
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
+ const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
+ const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted);
const [showFullHeaders, setShowFullHeaders] = useState(false);
const [allowExternalContent, setAllowExternalContent] = useState(false);
const [hasBlockedContent, setHasBlockedContent] = useState(false);
@@ -353,10 +355,16 @@ export function EmailViewer({
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur'],
};
+ // Check if sender is trusted
+ const senderEmail = email.from?.[0]?.email?.toLowerCase();
+ const senderIsTrusted = senderEmail ? isSenderTrusted(senderEmail) : false;
+
// Block external content based on policy:
- // 'allow' = never block, 'block' = always block, 'ask' = block until user allows
- const shouldBlockExternal = externalContentPolicy === 'block' ||
- (externalContentPolicy === 'ask' && !allowExternalContent);
+ // 'allow' = never block, 'block' = always block (unless trusted), 'ask' = block until user allows or trusted
+ const shouldBlockExternal = !senderIsTrusted && (
+ externalContentPolicy === 'block' ||
+ (externalContentPolicy === 'ask' && !allowExternalContent)
+ );
if (shouldBlockExternal) {
sanitizeConfig.FORBID_TAGS.push('link');
@@ -451,7 +459,7 @@ export function EmailViewer({
html: '
No content available
',
isHtml: false
};
- }, [email, allowExternalContent, hasBlockedContent, externalContentPolicy]);
+ }, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted]);
// Show loading skeleton while email is being fetched
if (isLoading && !email) {
@@ -1094,17 +1102,38 @@ export function EmailViewer({
{/* Email Content Area */}
- {/* Ultra Minimalist External Content Banner - only show in 'ask' mode */}
- {hasBlockedContent && !allowExternalContent && externalContentPolicy === 'ask' && (
+ {/* External Content Banner - show in 'ask' or 'block' mode */}
+ {hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && (
-
-
setAllowExternalContent(true)}
- className="mx-auto flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
- >
-
- Show images
-
+
+ {/* Load images button - only in 'ask' mode */}
+ {externalContentPolicy === 'ask' && (
+ setAllowExternalContent(true)}
+ className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
+ >
+
+ {t('load_external_content')}
+
+ )}
+ {/* Trust sender button - in both 'ask' and 'block' modes */}
+ {email.from?.[0]?.email && (
+ <>
+ {externalContentPolicy === 'ask' && | }
+ {
+ const senderEmail = email.from?.[0]?.email;
+ if (senderEmail) {
+ addTrustedSender(senderEmail);
+ setAllowExternalContent(true);
+ }
+ }}
+ className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
+ >
+ {t('trust_sender')}
+
+ >
+ )}
)}
diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx
index a62cc632..bfacdd26 100644
--- a/components/email/thread-conversation-view.tsx
+++ b/components/email/thread-conversation-view.tsx
@@ -75,6 +75,8 @@ export function ThreadConversationView({
}: ThreadConversationViewProps) {
const t = useTranslations();
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
+ const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
+ const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted);
// Track which emails are expanded (most recent by default)
const [expandedIds, setExpandedIds] = useState
>(new Set());
@@ -153,22 +155,30 @@ export function ThreadConversationView({
{/* Email Cards */}
- {emails.map((email, index) => (
- toggleExpanded(email.id)}
- onAllowExternal={() => toggleAllowExternal(email.id)}
- onReply={onReply ? () => onReply(email) : undefined}
- onReplyAll={onReplyAll ? () => onReplyAll(email) : undefined}
- onForward={onForward ? () => onForward(email) : undefined}
- onDownloadAttachment={onDownloadAttachment}
- onMarkAsRead={onMarkAsRead}
- />
- ))}
+ {emails.map((email, index) => {
+ const senderEmail = email.from?.[0]?.email?.toLowerCase();
+ const senderIsTrusted = senderEmail ? isSenderTrusted(senderEmail) : false;
+ return (
+ toggleExpanded(email.id)}
+ onAllowExternal={() => toggleAllowExternal(email.id)}
+ onTrustSender={senderEmail ? () => {
+ addTrustedSender(senderEmail);
+ toggleAllowExternal(email.id);
+ } : undefined}
+ onReply={onReply ? () => onReply(email) : undefined}
+ onReplyAll={onReplyAll ? () => onReplyAll(email) : undefined}
+ onForward={onForward ? () => onForward(email) : undefined}
+ onDownloadAttachment={onDownloadAttachment}
+ onMarkAsRead={onMarkAsRead}
+ />
+ );
+ })}
@@ -183,6 +193,7 @@ interface EmailCardProps {
allowExternal: boolean;
onToggleExpanded: () => void;
onAllowExternal: () => void;
+ onTrustSender?: () => void;
onReply?: () => void;
onReplyAll?: () => void;
onForward?: () => void;
@@ -197,6 +208,7 @@ function EmailCard({
allowExternal,
onToggleExpanded,
onAllowExternal,
+ onTrustSender,
onReply,
onReplyAll,
onForward,
@@ -382,16 +394,30 @@ function EmailCard({
{t("email_viewer.external_content_warning")}
-
{
- e.stopPropagation();
- onAllowExternal();
- }}
- >
- {t("email_viewer.load_external_content")}
-
+
+ {
+ e.stopPropagation();
+ onAllowExternal();
+ }}
+ >
+ {t("email_viewer.load_external_content")}
+
+ {onTrustSender && (
+ {
+ e.stopPropagation();
+ onTrustSender();
+ }}
+ >
+ {t("email_viewer.trust_sender")}
+
+ )}
+
)}
diff --git a/components/settings/email-settings.tsx b/components/settings/email-settings.tsx
index 8402ad08..baac6ff9 100644
--- a/components/settings/email-settings.tsx
+++ b/components/settings/email-settings.tsx
@@ -1,20 +1,34 @@
"use client";
+import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { useSettingsStore } from '@/stores/settings-store';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
+import { TrustedSendersModal } from '@/components/trusted-senders-modal';
+import { ChevronRight } from 'lucide-react';
export function EmailSettings() {
const t = useTranslations('settings.email_behavior');
+ const [showTrustedModal, setShowTrustedModal] = useState(false);
+
const {
markAsReadDelay,
deleteAction,
showPreview,
emailsPerPage,
externalContentPolicy,
+ trustedSenders,
updateSetting,
} = useSettingsStore();
+ // Get count label for trusted senders button
+ const getTrustedSendersCount = () => {
+ const count = trustedSenders.length;
+ if (count === 0) return t('trusted_senders.count_zero');
+ if (count === 1) return t('trusted_senders.count_one');
+ return t('trusted_senders.count_other', { count });
+ };
+
return (
{/* Mark as Read */}
@@ -75,6 +89,23 @@ export function EmailSettings() {
]}
/>
+
+ {/* Trusted Senders */}
+
+ setShowTrustedModal(true)}
+ className="flex items-center gap-2 px-3 py-1.5 bg-muted hover:bg-accent rounded-md transition-colors"
+ >
+ {getTrustedSendersCount()}
+
+
+
+
+ {/* Trusted Senders Modal */}
+ setShowTrustedModal(false)}
+ />
);
}
diff --git a/components/trusted-senders-modal.tsx b/components/trusted-senders-modal.tsx
new file mode 100644
index 00000000..89dbc26d
--- /dev/null
+++ b/components/trusted-senders-modal.tsx
@@ -0,0 +1,270 @@
+"use client";
+
+import { useState, useEffect, useRef, useMemo } from "react";
+import { useTranslations } from "next-intl";
+import { X, ShieldCheck, Search, Trash2, Plus } from "lucide-react";
+import { Avatar } from "@/components/ui/avatar";
+import { useSettingsStore } from "@/stores/settings-store";
+import { cn } from "@/lib/utils";
+
+interface TrustedSendersModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+}
+
+export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProps) {
+ const t = useTranslations("settings.email_behavior.trusted_senders");
+ const modalRef = useRef
(null);
+ const inputRef = useRef(null);
+
+ const { trustedSenders, addTrustedSender, removeTrustedSender } = useSettingsStore();
+
+ const [searchQuery, setSearchQuery] = useState("");
+ const [isAdding, setIsAdding] = useState(false);
+ const [newEmail, setNewEmail] = useState("");
+ const [emailError, setEmailError] = useState("");
+
+ // Filter senders based on search query
+ const filteredSenders = useMemo(() => {
+ if (!searchQuery.trim()) return trustedSenders;
+ const query = searchQuery.toLowerCase();
+ return trustedSenders.filter((email) => email.toLowerCase().includes(query));
+ }, [trustedSenders, searchQuery]);
+
+ // Show search only when 5+ senders
+ const showSearch = trustedSenders.length >= 5;
+
+ // Close on Escape key
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ if (isAdding) {
+ setIsAdding(false);
+ setNewEmail("");
+ setEmailError("");
+ } else {
+ onClose();
+ }
+ }
+ };
+
+ if (isOpen) {
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }
+ }, [isOpen, isAdding, onClose]);
+
+ // Close on click outside
+ useEffect(() => {
+ const handleClickOutside = (e: MouseEvent) => {
+ if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
+ onClose();
+ }
+ };
+
+ if (isOpen) {
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => document.removeEventListener("mousedown", handleClickOutside);
+ }
+ }, [isOpen, onClose]);
+
+ // Focus input when adding mode is enabled
+ useEffect(() => {
+ if (isAdding && inputRef.current) {
+ inputRef.current.focus();
+ }
+ }, [isAdding]);
+
+ // Reset state when modal closes
+ useEffect(() => {
+ if (!isOpen) {
+ setSearchQuery("");
+ setIsAdding(false);
+ setNewEmail("");
+ setEmailError("");
+ }
+ }, [isOpen]);
+
+ const validateEmail = (email: string): boolean => {
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ return emailRegex.test(email);
+ };
+
+ const handleAddSender = () => {
+ const trimmedEmail = newEmail.trim().toLowerCase();
+
+ if (!trimmedEmail) {
+ setEmailError(t("invalid_email"));
+ return;
+ }
+
+ if (!validateEmail(trimmedEmail)) {
+ setEmailError(t("invalid_email"));
+ return;
+ }
+
+ if (trustedSenders.includes(trimmedEmail)) {
+ setEmailError(t("already_added"));
+ return;
+ }
+
+ addTrustedSender(trimmedEmail);
+ setNewEmail("");
+ setIsAdding(false);
+ setEmailError("");
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter") {
+ handleAddSender();
+ }
+ };
+
+ if (!isOpen) return null;
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
+ {t("modal_title")}
+
+
+
+
+
+
+
+ {/* Search (only when 5+ senders) */}
+ {showSearch && (
+
+
+
+ setSearchQuery(e.target.value)}
+ className="w-full pl-9 pr-3 py-2 text-sm bg-muted border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-primary/50"
+ />
+
+
+ )}
+
+ {/* Content */}
+
+ {trustedSenders.length === 0 ? (
+ /* Empty State */
+
+
+
+ {t("empty_title")}
+
+
+ {t("empty_description")}
+
+
setIsAdding(true)}
+ className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors text-sm font-medium"
+ >
+
+ {t("add_manually")}
+
+
+ ) : filteredSenders.length === 0 ? (
+ /* No search results */
+
+
+
+ {t("no_results")}
+
+
+ ) : (
+ /* Sender list */
+
+ {filteredSenders.map((email) => (
+
+
+
+ {email}
+
+
removeTrustedSender(email)}
+ className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100"
+ aria-label={`${t("remove")} ${email}`}
+ >
+
+
+
+ ))}
+
+ )}
+
+
+ {/* Footer - Add sender */}
+ {trustedSenders.length > 0 && (
+
+ {isAdding ? (
+
+
+ {
+ setNewEmail(e.target.value);
+ setEmailError("");
+ }}
+ onKeyDown={handleKeyDown}
+ className={cn(
+ "flex-1 px-3 py-2 text-sm bg-background border rounded-md focus:outline-none focus:ring-2 focus:ring-primary/50",
+ emailError ? "border-destructive" : "border-border"
+ )}
+ />
+
+ {t("add_button")}
+
+
+ {emailError && (
+
{emailError}
+ )}
+
+ ) : (
+
setIsAdding(true)}
+ className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
+ >
+
+ {t("add_manually")}
+
+ )}
+
+ )}
+
+
+ );
+}
diff --git a/hooks/use-config.ts b/hooks/use-config.ts
new file mode 100644
index 00000000..8f75fdd2
--- /dev/null
+++ b/hooks/use-config.ts
@@ -0,0 +1,93 @@
+"use client";
+
+import { useState, useEffect } from 'react';
+
+interface AppConfig {
+ appName: string;
+ jmapServerUrl: string;
+ isLoading: boolean;
+ error: string | null;
+}
+
+// Cache the config to avoid multiple fetches
+let configCache: { appName: string; jmapServerUrl: string } | null = null;
+let configPromise: Promise<{ appName: string; jmapServerUrl: string }> | null = null;
+
+async function fetchConfig(): Promise<{ appName: string; jmapServerUrl: string }> {
+ // Return cached config if available
+ if (configCache) {
+ return configCache;
+ }
+
+ // If a fetch is already in progress, wait for it
+ if (configPromise) {
+ return configPromise;
+ }
+
+ // Start a new fetch
+ configPromise = fetch('/api/config')
+ .then((res) => {
+ if (!res.ok) {
+ throw new Error('Failed to fetch config');
+ }
+ return res.json();
+ })
+ .then((data) => {
+ configCache = data;
+ return data;
+ })
+ .finally(() => {
+ configPromise = null;
+ });
+
+ return configPromise;
+}
+
+/**
+ * Hook to fetch runtime configuration
+ *
+ * Fetches app configuration from /api/config endpoint, which reads
+ * environment variables at runtime (not build time).
+ *
+ * The config is cached after first fetch to avoid unnecessary requests.
+ */
+export function useConfig(): AppConfig {
+ const [config, setConfig] = useState({
+ appName: configCache?.appName || 'Webmail',
+ jmapServerUrl: configCache?.jmapServerUrl || '',
+ isLoading: !configCache,
+ error: null,
+ });
+
+ useEffect(() => {
+ // If already cached, no need to fetch
+ if (configCache) {
+ setConfig({
+ appName: configCache.appName,
+ jmapServerUrl: configCache.jmapServerUrl,
+ isLoading: false,
+ error: null,
+ });
+ return;
+ }
+
+ fetchConfig()
+ .then((data) => {
+ setConfig({
+ appName: data.appName,
+ jmapServerUrl: data.jmapServerUrl,
+ isLoading: false,
+ error: null,
+ });
+ })
+ .catch((err) => {
+ setConfig((prev) => ({
+ ...prev,
+ isLoading: false,
+ error: err.message,
+ }));
+ });
+ }, []);
+
+ return config;
+}
diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts
index 313ae604..79f98dfc 100644
--- a/lib/jmap/client.ts
+++ b/lib/jmap/client.ts
@@ -1,4 +1,4 @@
-import type { Email, Mailbox, StateChange, AccountStates, Thread } from "./types";
+import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity } from "./types";
// JMAP protocol types - these are intentionally flexible due to server variations
interface JMAPSession {
@@ -900,6 +900,26 @@ export class JMAPClient {
}
}
+ async getIdentities(): Promise {
+ try {
+ const response = await this.request([
+ ["Identity/get", {
+ accountId: this.accountId,
+ }, "0"]
+ ]);
+
+ if (response.methodResponses?.[0]?.[0] === "Identity/get") {
+ const identities = (response.methodResponses[0][1].list || []) as Identity[];
+ return identities;
+ }
+
+ return [];
+ } catch (error) {
+ console.error('Failed to get identities:', error);
+ return [];
+ }
+ }
+
async createDraft(
to: string[],
subject: string,
@@ -907,7 +927,8 @@ export class JMAPClient {
cc?: string[],
bcc?: string[],
draftId?: string,
- attachments?: Array<{ blobId: string; name: string; type: string; size: number }>
+ attachments?: Array<{ blobId: string; name: string; type: string; size: number }>,
+ fromEmail?: string
): Promise {
// Find the drafts mailbox
const mailboxes = await this.getMailboxes();
@@ -933,7 +954,7 @@ export class JMAPClient {
attachments?: { blobId: string; type: string; name: string; disposition: string }[];
}
const emailData: EmailDraft = {
- from: [{ email: this.username }],
+ from: [{ email: fromEmail || this.username }],
to: to.map(email => ({ email })),
cc: cc?.map(email => ({ email })),
bcc: bcc?.map(email => ({ email })),
@@ -1025,7 +1046,9 @@ export class JMAPClient {
body: string,
cc?: string[],
bcc?: string[],
- draftId?: string
+ draftId?: string,
+ fromEmail?: string,
+ selectedIdentityId?: string
): Promise {
const emailId = draftId || `draft-${Date.now()}`;
@@ -1037,22 +1060,26 @@ export class JMAPClient {
throw new Error('No sent mailbox found');
}
- // Get the identity ID - fetch identities from server
- const identityResponse = await this.request([
- ["Identity/get", {
- accountId: this.accountId,
- }, "0"]
- ]);
+ // Use provided identity ID or fetch from server as fallback
+ let identityId = selectedIdentityId;
- let identityId = this.accountId; // fallback
+ if (!identityId) {
+ const identityResponse = await this.request([
+ ["Identity/get", {
+ accountId: this.accountId,
+ }, "0"]
+ ]);
- if (identityResponse.methodResponses?.[0]?.[0] === "Identity/get") {
- const identities = (identityResponse.methodResponses[0][1].list || []) as { id: string; email: string }[];
+ identityId = this.accountId; // fallback
- if (identities.length > 0) {
- // Use the first identity (or find one matching the username)
- const matchingIdentity = identities.find((id) => id.email === this.username);
- identityId = matchingIdentity?.id || identities[0].id;
+ if (identityResponse.methodResponses?.[0]?.[0] === "Identity/get") {
+ const identities = (identityResponse.methodResponses[0][1].list || []) as { id: string; email: string }[];
+
+ if (identities.length > 0) {
+ // Use the first identity (or find one matching the fromEmail/username)
+ const matchingIdentity = identities.find((id) => id.email === (fromEmail || this.username));
+ identityId = matchingIdentity?.id || identities[0].id;
+ }
}
}
@@ -1085,7 +1112,7 @@ export class JMAPClient {
accountId: this.accountId,
create: {
[emailId]: {
- from: [{ email: this.username }],
+ from: [{ email: fromEmail || this.username }],
to: to.map(email => ({ email })),
cc: cc?.map(email => ({ email })),
bcc: bcc?.map(email => ({ email })),
diff --git a/locales/en/common.json b/locales/en/common.json
index 3cc8718e..317bcef7 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -7,11 +7,18 @@
"password_placeholder": "Enter your password",
"sign_in": "Sign in",
"signing_in": "Signing in...",
+ "loading": "Loading...",
"error": {
"invalid_credentials": "Invalid email or password",
"connection_failed": "Failed to connect to the server",
"generic": "An error occurred. Please try again."
- }
+ },
+ "config_error": {
+ "title": "Configuration Error",
+ "fetch_failed": "Unable to load application configuration. Please try again later.",
+ "server_not_configured": "The mail server has not been configured. Please contact your administrator."
+ },
+ "remove_from_history": "Remove from history"
},
"sidebar": {
"compose": "Compose",
@@ -82,6 +89,7 @@
"hide_details": "Hide details",
"external_content_warning": "Images and external content have been blocked",
"load_external_content": "Load images",
+ "trust_sender": "Always trust this sender",
"message_details": "Message Details",
"authentication": {
"title": "Authentication",
@@ -132,6 +140,7 @@
"reply_to": "Reply",
"reply_all_to": "Reply All",
"forward_message": "Forward",
+ "from": "From",
"to": "To",
"cc": "CC",
"bcc": "BCC",
@@ -139,7 +148,8 @@
"body_placeholder": "Write your message...",
"send": "Send",
"cancel": "Cancel",
- "attach": "Attach files",
+ "attach": "Attach",
+ "discard": "Discard",
"discard_draft_confirm": "You have unsaved changes. Do you want to discard this draft?",
"quote": {
"reply_header": "On {{date}}, {{sender}} wrote:",
@@ -313,6 +323,25 @@
"ask": "Always ask",
"block": "Always block",
"allow": "Always allow"
+ },
+ "trusted_senders": {
+ "label": "Trusted Senders",
+ "description": "Manage senders whose images load automatically",
+ "count_zero": "None",
+ "count_one": "1 sender",
+ "count_other": "{count} senders",
+ "modal_title": "Trusted Senders",
+ "empty_title": "No trusted senders yet",
+ "empty_description": "When viewing an email with blocked images, click \"Always trust this sender\" to add them here.",
+ "add_manually": "Add sender manually",
+ "add_button": "Add",
+ "add_placeholder": "Enter email address",
+ "search_placeholder": "Search senders...",
+ "no_results": "No senders match your search",
+ "remove": "Remove",
+ "close": "Close",
+ "invalid_email": "Please enter a valid email address",
+ "already_added": "This sender is already trusted"
}
},
"composer": {
diff --git a/locales/fr/common.json b/locales/fr/common.json
index 7b641b1f..c2dfd302 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -7,11 +7,18 @@
"password_placeholder": "Entrez votre mot de passe",
"sign_in": "Se connecter",
"signing_in": "Connexion en cours...",
+ "loading": "Chargement...",
"error": {
"invalid_credentials": "Email ou mot de passe invalide",
"connection_failed": "Échec de la connexion au serveur",
"generic": "Une erreur s'est produite. Veuillez réessayer."
- }
+ },
+ "config_error": {
+ "title": "Erreur de configuration",
+ "fetch_failed": "Impossible de charger la configuration de l'application. Veuillez réessayer plus tard.",
+ "server_not_configured": "Le serveur de messagerie n'a pas été configuré. Veuillez contacter votre administrateur."
+ },
+ "remove_from_history": "Supprimer de l'historique"
},
"sidebar": {
"compose": "Composer",
@@ -82,6 +89,7 @@
"hide_details": "Masquer les détails",
"external_content_warning": "Les images et le contenu externe ont été bloqués",
"load_external_content": "Charger les images",
+ "trust_sender": "Toujours faire confiance à cet expéditeur",
"message_details": "Détails du message",
"authentication": {
"title": "Authentification",
@@ -132,6 +140,7 @@
"reply_to": "Répondre",
"reply_all_to": "Répondre à tous",
"forward_message": "Transférer",
+ "from": "De",
"to": "À",
"cc": "CC",
"bcc": "CCI",
@@ -139,7 +148,8 @@
"body_placeholder": "Écrivez votre message...",
"send": "Envoyer",
"cancel": "Annuler",
- "attach": "Joindre des fichiers",
+ "attach": "Joindre",
+ "discard": "Supprimer",
"discard_draft_confirm": "Vous avez des modifications non enregistrées. Voulez-vous supprimer ce brouillon ?",
"quote": {
"reply_header": "Le {{date}}, {{sender}} a écrit :",
@@ -313,6 +323,25 @@
"ask": "Toujours demander",
"block": "Toujours bloquer",
"allow": "Toujours autoriser"
+ },
+ "trusted_senders": {
+ "label": "Expéditeurs de confiance",
+ "description": "Gérer les expéditeurs dont les images se chargent automatiquement",
+ "count_zero": "Aucun",
+ "count_one": "1 expéditeur",
+ "count_other": "{count} expéditeurs",
+ "modal_title": "Expéditeurs de confiance",
+ "empty_title": "Aucun expéditeur de confiance",
+ "empty_description": "Lorsque vous consultez un email avec des images bloquées, cliquez sur « Toujours faire confiance à cet expéditeur » pour l'ajouter ici.",
+ "add_manually": "Ajouter manuellement",
+ "add_button": "Ajouter",
+ "add_placeholder": "Entrez une adresse email",
+ "search_placeholder": "Rechercher...",
+ "no_results": "Aucun expéditeur ne correspond à votre recherche",
+ "remove": "Supprimer",
+ "close": "Fermer",
+ "invalid_email": "Veuillez entrer une adresse email valide",
+ "already_added": "Cet expéditeur est déjà de confiance"
}
},
"composer": {
diff --git a/package.json b/package.json
index 39f49cb8..283b7fe8 100644
--- a/package.json
+++ b/package.json
@@ -37,28 +37,28 @@
"dompurify": "^3.2.7",
"jmap-jam": "^0.13.1",
"lucide-react": "^0.562.0",
- "next": "^16.1.1",
+ "next": "^16.0.8",
"next-auth": "^4.24.11",
- "next-intl": "^4.6.1",
- "react": "^19.2.3",
- "react-dom": "^19.2.3",
+ "next-intl": "^4.5.8",
+ "react": "^19.2.1",
+ "react-dom": "^19.2.1",
"tailwind-merge": "^3.3.1",
"zustand": "^5.0.9"
},
"devDependencies": {
- "@tailwindcss/postcss": "^4.1.18",
+ "@tailwindcss/postcss": "^4",
"@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19",
- "@typescript-eslint/eslint-plugin": "^8.50.1",
- "@typescript-eslint/parser": "^8.50.1",
- "eslint": "^9.39.2",
- "eslint-config-next": "^16.1.1",
+ "@typescript-eslint/eslint-plugin": "^8.49.0",
+ "@typescript-eslint/parser": "^8.49.0",
+ "eslint": "^9.39.1",
+ "eslint-config-next": "^16.0.8",
"eslint-plugin-react": "^7.37.5",
"globals": "^16.5.0",
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
- "tailwindcss": "^4.1.18",
+ "tailwindcss": "^4.1.17",
"typescript": "^5"
}
}
diff --git a/stores/auth-store.ts b/stores/auth-store.ts
index 85c0b203..e9b65c74 100644
--- a/stores/auth-store.ts
+++ b/stores/auth-store.ts
@@ -2,6 +2,7 @@ import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { JMAPClient } from '@/lib/jmap/client';
import { useEmailStore } from './email-store';
+import type { Identity } from '@/lib/jmap/types';
interface AuthState {
isAuthenticated: boolean;
@@ -10,6 +11,8 @@ interface AuthState {
serverUrl: string | null;
username: string | null;
client: JMAPClient | null;
+ identities: Identity[];
+ primaryIdentity: Identity | null;
login: (serverUrl: string, username: string, password: string) => Promise;
logout: () => void;
@@ -26,6 +29,8 @@ export const useAuthStore = create()(
serverUrl: null,
username: null,
client: null,
+ identities: [],
+ primaryIdentity: null,
login: async (serverUrl, username, password) => {
set({ isLoading: true, error: null });
@@ -37,6 +42,10 @@ export const useAuthStore = create()(
// Try to connect
await client.connect();
+ // Fetch identities from the server
+ const identities = await client.getIdentities();
+ const primaryIdentity = identities.length > 0 ? identities[0] : null;
+
// Success - save state (but NOT the password)
set({
isAuthenticated: true,
@@ -44,6 +53,8 @@ export const useAuthStore = create()(
serverUrl,
username,
client,
+ identities,
+ primaryIdentity,
error: null,
});
@@ -87,6 +98,8 @@ export const useAuthStore = create()(
serverUrl: null,
username: null,
client: null,
+ identities: [],
+ primaryIdentity: null,
error: null,
});
diff --git a/stores/email-store.ts b/stores/email-store.ts
index 6a5ca4ae..12f5a189 100644
--- a/stores/email-store.ts
+++ b/stores/email-store.ts
@@ -46,7 +46,7 @@ interface EmailStore {
loadMoreEmails: (client: JMAPClient) => Promise;
fetchEmailContent: (client: JMAPClient, emailId: string) => Promise;
fetchQuota: (client: JMAPClient) => Promise;
- sendEmail: (client: JMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], draftId?: string) => Promise;
+ sendEmail: (client: JMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], draftId?: string, fromEmail?: string, identityId?: string) => Promise;
deleteEmail: (client: JMAPClient, emailId: string) => Promise;
markAsRead: (client: JMAPClient, emailId: string, read: boolean) => Promise;
moveToMailbox: (client: JMAPClient, emailId: string, mailboxId: string) => Promise;
@@ -282,10 +282,10 @@ export const useEmailStore = create((set, get) => ({
}
},
- sendEmail: async (client, to, subject, body, cc, bcc, draftId) => {
+ sendEmail: async (client, to, subject, body, cc, bcc, draftId, fromEmail, identityId) => {
set({ isLoading: true, error: null });
try {
- await client.sendEmail(to, subject, body, cc, bcc, draftId);
+ await client.sendEmail(to, subject, body, cc, bcc, draftId, fromEmail, identityId);
// Refresh emails after sending
await get().fetchEmails(client);
set({ isLoading: false });
diff --git a/stores/settings-store.ts b/stores/settings-store.ts
index ba16793b..255fc125 100644
--- a/stores/settings-store.ts
+++ b/stores/settings-store.ts
@@ -35,6 +35,7 @@ interface SettingsState {
// Privacy & Security
sessionTimeout: number; // minutes (0 = never)
+ trustedSenders: string[]; // Email addresses that can load external content
// Advanced
debugMode: boolean;
@@ -47,6 +48,11 @@ interface SettingsState {
resetToDefaults: () => void;
exportSettings: () => string;
importSettings: (json: string) => boolean;
+
+ // Trusted senders
+ addTrustedSender: (email: string) => void;
+ removeTrustedSender: (email: string) => void;
+ isSenderTrusted: (email: string) => boolean;
}
const DEFAULT_SETTINGS = {
@@ -74,6 +80,7 @@ const DEFAULT_SETTINGS = {
// Privacy & Security
sessionTimeout: 0, // Never
+ trustedSenders: [] as string[],
// Advanced
debugMode: false,
@@ -124,6 +131,7 @@ export const useSettingsStore = create()(
showPreview: state.showPreview,
emailsPerPage: state.emailsPerPage,
externalContentPolicy: state.externalContentPolicy,
+ trustedSenders: state.trustedSenders,
autoSaveDraftInterval: state.autoSaveDraftInterval,
sendConfirmation: state.sendConfirmation,
defaultReplyMode: state.defaultReplyMode,
@@ -160,6 +168,27 @@ export const useSettingsStore = create()(
return false;
}
},
+
+ // Trusted senders methods
+ addTrustedSender: (email: string) => {
+ const normalizedEmail = email.toLowerCase().trim();
+ const current = get().trustedSenders;
+ if (!current.includes(normalizedEmail)) {
+ set({ trustedSenders: [...current, normalizedEmail] });
+ }
+ },
+
+ removeTrustedSender: (email: string) => {
+ const normalizedEmail = email.toLowerCase().trim();
+ set({
+ trustedSenders: get().trustedSenders.filter(e => e !== normalizedEmail)
+ });
+ },
+
+ isSenderTrusted: (email: string) => {
+ const normalizedEmail = email.toLowerCase().trim();
+ return get().trustedSenders.includes(normalizedEmail);
+ },
}),
{
name: 'settings-storage',