Fix: send mailto unsubscribe ourselves instead of via the OS handler

The List-Unsubscribe action for mailto: links created a hidden anchor,
clicked it and reported success. That hands the mailto: URL to the OS
default mail handler - for a webmail user that opens the wrong program
or nothing at all, and the unsubscribe message is never sent, while the
banner still claims it was.

The confirm flow now parses the mailto: URL (address, subject, body -
percent-decoded manually since RFC 6068 does not use plus-encoding) and
sends the message through the account's own JMAP client, preferring the
identity that received the newsletter so the list can match the
subscriber. In unified views the send is routed to the email's owning
account. Success is only reported once the server accepted the message.

The mobile confirm dialog reused the success strings as its question
text; it gets proper confirm_message strings in all 22 locales, and
success_mailto now says what actually happened.
This commit is contained in:
dealerweb
2026-07-08 15:41:46 +02:00
committed by Linus Rath
parent 22418c17cf
commit 94af4725b6
26 changed files with 185 additions and 31 deletions
+24
View File
@@ -714,6 +714,28 @@ export function EmailViewer({
const { tabletListVisible } = useUIStore();
const { identities, client, isDemoMode, activeAccountId } = useAuthStore();
const activeAccount = useAccountStore((s) => s.accounts.find((a) => a.id === activeAccountId));
// List-Unsubscribe mailto: send the message ourselves - this is a webmail
// client, handing a mailto: URL to the OS mail handler goes nowhere for
// most users. Route to the email's own account in unified views and prefer
// the identity that received the newsletter, so the list can match the
// subscriber; sendEmail resolves the identity (with its own fallback to
// the account default) from the address we pass.
const handleSendMailtoUnsubscribe = async (fields: { to: string[]; subject?: string; body?: string }) => {
const sendClient = (email?.sourceClientAccountId
? useAuthStore.getState().getClientForAccount(email.sourceClientAccountId)
: undefined) ?? client;
if (!sendClient) throw new Error('Not connected');
const recipientAddresses = [...(email?.to ?? []), ...(email?.cc ?? [])].map(r => r.email?.toLowerCase());
// In unified views the owning account's identities are not loaded here -
// pass nothing and let its client fall back to its default identity.
const fromIdentity = email?.sourceClientAccountId
? undefined
: identities.find(i => i.email && recipientAddresses.includes(i.email.toLowerCase()));
await sendClient.sendEmail(fields.to, fields.subject ?? '', fields.body ?? '', undefined, undefined, fromIdentity?.id, fromIdentity?.email, undefined, fromIdentity?.name);
};
const promptForRescheduleDelayedUntil = useCallback((): string | null => {
const value = window.prompt(t('reschedule_prompt'));
if (!value) return null;
@@ -3579,6 +3601,7 @@ export function EmailViewer({
<UnsubscribeBanner
listUnsubscribe={listHeaders.listUnsubscribe}
senderEmail={email?.from?.[0]?.email || ''}
onSendMailtoUnsubscribe={handleSendMailtoUnsubscribe}
onDismiss={() => {
const messageId = email?.messageId || '';
const newSet = new Set(dismissedUnsubBanners).add(messageId);
@@ -3824,6 +3847,7 @@ export function EmailViewer({
<UnsubscribeBanner
listUnsubscribe={listHeaders.listUnsubscribe}
senderEmail={email?.from?.[0]?.email || ''}
onSendMailtoUnsubscribe={handleSendMailtoUnsubscribe}
onDismiss={() => {
const messageId = email?.messageId || '';
const newSet = new Set(dismissedUnsubBanners).add(messageId);
+20 -9
View File
@@ -3,7 +3,7 @@
import { useState, useRef, useEffect } from 'react';
import { Loader2, CheckCircle, AlertCircle } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { isValidUnsubscribeUrl } from '@/lib/validation';
import { isValidUnsubscribeUrl, parseMailtoUrl } from '@/lib/validation';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { useIsDesktop } from '@/hooks/use-media-query';
@@ -14,12 +14,17 @@ interface UnsubscribeBannerProps {
preferred?: 'http' | 'mailto';
};
senderEmail: string;
// Sends the unsubscribe message through the app's own account. This is a
// webmail client - handing a mailto: URL to the OS mail handler goes
// nowhere for most users.
onSendMailtoUnsubscribe: (fields: { to: string[]; subject?: string; body?: string }) => Promise<void>;
onDismiss: () => void;
}
export function UnsubscribeBanner({
listUnsubscribe,
senderEmail: _senderEmail,
onSendMailtoUnsubscribe,
onDismiss
}: UnsubscribeBannerProps) {
const t = useTranslations();
@@ -74,12 +79,18 @@ export function UnsubscribeBanner({
setShowConfirm(false);
setTimeout(onDismiss, 3000);
} else {
const link = document.createElement('a');
link.href = unsubUrl;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Send the unsubscribe message ourselves and only report success
// once the server accepted it. The previous hidden-link click handed
// the mailto: to the OS mail handler and claimed success even though
// nothing was ever sent.
const fields = parseMailtoUrl(unsubUrl);
if (!fields) {
setError(true);
setProcessing(false);
setShowConfirm(false);
return;
}
await onSendMailtoUnsubscribe(fields);
setSuccess(true);
setProcessing(false);
@@ -171,8 +182,8 @@ export function UnsubscribeBanner({
}}
title={t('email_viewer.unsubscribe_banner.confirm_title')}
message={t(unsubMethod === 'http'
? 'email_viewer.unsubscribe_banner.success_http'
: 'email_viewer.unsubscribe_banner.success_mailto'
? 'email_viewer.unsubscribe_banner.confirm_message_http'
: 'email_viewer.unsubscribe_banner.confirm_message_mailto'
)}
confirmText={t('email_viewer.unsubscribe_banner.confirm_button')}
cancelText={t('email_viewer.unsubscribe_banner.cancel')}