diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 7055e928..ca98cffd 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -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({ { const messageId = email?.messageId || ''; const newSet = new Set(dismissedUnsubBanners).add(messageId); @@ -3824,6 +3847,7 @@ export function EmailViewer({ { const messageId = email?.messageId || ''; const newSet = new Set(dismissedUnsubBanners).add(messageId); diff --git a/components/email/unsubscribe-banner.tsx b/components/email/unsubscribe-banner.tsx index 00736e69..01a53dad 100644 --- a/components/email/unsubscribe-banner.tsx +++ b/components/email/unsubscribe-banner.tsx @@ -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; 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')} diff --git a/lib/__tests__/validation.test.ts b/lib/__tests__/validation.test.ts index a1238968..e096a37c 100644 --- a/lib/__tests__/validation.test.ts +++ b/lib/__tests__/validation.test.ts @@ -5,6 +5,7 @@ import { getEmailValidationError, isValidUnsubscribeUrl, parseUnsubscribeUrls, + parseMailtoUrl, } from '../validation'; describe('validation', () => { @@ -359,3 +360,33 @@ describe('validation', () => { }); }); }); + +describe('parseMailtoUrl', () => { + it('parses address, subject and body', () => { + const r = parseMailtoUrl('mailto:list@example.com?subject=Unsubscribe%20123&body=Please%20remove'); + expect(r).toEqual({ to: ['list@example.com'], subject: 'Unsubscribe 123', body: 'Please remove' }); + }); + + it('keeps a literal plus (RFC 6068 uses percent-encoding only)', () => { + const r = parseMailtoUrl('mailto:owner+unsub@example.com?subject=a+b'); + expect(r?.to).toEqual(['owner+unsub@example.com']); + expect(r?.subject).toBe('a+b'); + }); + + it('supports multiple recipients and the to param', () => { + const r = parseMailtoUrl('mailto:a@example.com,b@example.com?to=c@example.com'); + expect(r?.to).toEqual(['a@example.com', 'b@example.com', 'c@example.com']); + }); + + it('returns null without a valid recipient', () => { + expect(parseMailtoUrl('mailto:?subject=x')).toBeNull(); + expect(parseMailtoUrl('mailto:not-an-address')).toBeNull(); + expect(parseMailtoUrl('https://example.com/unsub')).toBeNull(); + }); + + it('survives malformed percent-encoding', () => { + const r = parseMailtoUrl('mailto:list@example.com?subject=%E0%A4%A'); + expect(r?.to).toEqual(['list@example.com']); + expect(r?.subject).toBe('%E0%A4%A'); + }); +}); diff --git a/lib/validation.ts b/lib/validation.ts index cb6b3f0c..0c2a5c77 100644 --- a/lib/validation.ts +++ b/lib/validation.ts @@ -121,3 +121,47 @@ export function parseUnsubscribeUrls(header: string): { return { http, mailto, preferred }; } + +/** + * Parse a mailto: URL into its parts so the client can send the message + * itself. Query values are percent-decoded manually rather than via + * URLSearchParams because RFC 6068 uses %-encoding only - a literal "+" + * in a subject or address must stay a plus, not become a space. + * @param url - mailto: URL, e.g. "mailto:a@b.c?subject=Unsubscribe%20123" + * @returns Recipients plus optional subject/body, or null without a valid recipient + */ +export function parseMailtoUrl(url: string): { to: string[]; subject?: string; body?: string } | null { + if (!url?.startsWith('mailto:')) return null; + + const rest = url.slice(7); + const queryIndex = rest.indexOf('?'); + const addressPart = queryIndex === -1 ? rest : rest.slice(0, queryIndex); + const query = queryIndex === -1 ? '' : rest.slice(queryIndex + 1); + + const decode = (value: string): string => { + try { + return decodeURIComponent(value); + } catch { + return value; + } + }; + + const to = addressPart + .split(',') + .map(a => decode(a).trim()) + .filter(a => isValidEmail(a)); + + let subject: string | undefined; + let body: string | undefined; + for (const pair of query.split('&')) { + const eq = pair.indexOf('='); + if (eq === -1) continue; + const key = pair.slice(0, eq).toLowerCase(); + const value = decode(pair.slice(eq + 1)); + if (key === 'subject') subject = value; + else if (key === 'body') body = value; + else if (key === 'to' && isValidEmail(value.trim())) to.push(value.trim()); + } + + return to.length > 0 ? { to, subject, body } : null; +} diff --git a/locales/cs/common.json b/locales/cs/common.json index 2441349d..d2f0686b 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -436,7 +436,9 @@ "confirm_button": "Potvrdit", "cancel": "Zrušit", "success_http": "Stránka pro odhlášení byla otevřena na nové kartě", - "success_mailto": "Požadavek na odhlášení byl odeslán do e-mailového klienta", + "success_mailto": "Odhlašovací e-mail byl odeslán", + "confirm_message_http": "Odhlašovací stránka se otevře na nové kartě.", + "confirm_message_mailto": "Odesílateli bude zaslán odhlašovací e-mail.", "error": "Odběr nelze odhlásit", "dismiss": "Zavřít" }, diff --git a/locales/da/common.json b/locales/da/common.json index 08717b4a..9bfefea7 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -461,7 +461,9 @@ "confirm_button": "Bekræft", "cancel": "Annuller", "success_http": "Afmeldingsside åbnet i ny fane", - "success_mailto": "Afmeldingsanmodning sendt til din e-mailklient", + "success_mailto": "Afmeldings-e-mail sendt", + "confirm_message_http": "Afmeldingssiden åbnes i en ny fane.", + "confirm_message_mailto": "Der sendes en afmeldings-e-mail til afsenderen.", "error": "Kan ikke afmelde", "dismiss": "Afvis" }, diff --git a/locales/de/common.json b/locales/de/common.json index 113b6c3e..7c912e69 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -434,7 +434,9 @@ "confirm_button": "Bestätigen", "cancel": "Abbrechen", "success_http": "Abmeldeseite in neuem Tab geöffnet", - "success_mailto": "Abmeldeanfrage an Ihr E-Mail-Programm gesendet", + "success_mailto": "Abmelde-E-Mail gesendet", + "confirm_message_http": "Die Abmeldeseite wird in einem neuen Tab geöffnet.", + "confirm_message_mailto": "Es wird eine Abmelde-E-Mail an den Absender gesendet.", "error": "Abmeldung nicht möglich", "dismiss": "Schließen" }, diff --git a/locales/en/common.json b/locales/en/common.json index e8a451e7..9f6b1109 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -461,7 +461,9 @@ "confirm_button": "Confirm", "cancel": "Cancel", "success_http": "Unsubscribe page opened in new tab", - "success_mailto": "Unsubscribe request sent to your email client", + "success_mailto": "Unsubscribe email sent", + "confirm_message_http": "The unsubscribe page will open in a new tab.", + "confirm_message_mailto": "An unsubscribe email will be sent to the sender.", "error": "Unable to unsubscribe", "dismiss": "Dismiss" }, diff --git a/locales/es/common.json b/locales/es/common.json index 362149a3..5bb9b924 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -434,7 +434,9 @@ "confirm_button": "Confirmar", "cancel": "Cancelar", "success_http": "Página de cancelación de suscripción abierta en nueva pestaña", - "success_mailto": "Solicitud de cancelación enviada a su cliente de correo", + "success_mailto": "Correo de cancelación enviado", + "confirm_message_http": "La página de cancelación se abrirá en una pestaña nueva.", + "confirm_message_mailto": "Se enviará un correo de cancelación al remitente.", "error": "No se pudo cancelar la suscripción", "dismiss": "Descartar" }, diff --git a/locales/fa/common.json b/locales/fa/common.json index 1d9696f6..2e890be6 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -461,7 +461,9 @@ "confirm_button": "تأیید", "cancel": "انصراف", "success_http": "صفحه لغو اشتراک در تب جدید باز شد", - "success_mailto": "درخواست لغو اشتراک ارسال شد", + "success_mailto": "ایمیل لغو اشتراک ارسال شد", + "confirm_message_http": "صفحه لغو اشتراک در برگه جدیدی باز خواهد شد.", + "confirm_message_mailto": "ایمیل لغو اشتراک برای فرستنده ارسال خواهد شد.", "error": "لغو اشتراک ممکن نیست", "dismiss": "رد کردن" }, diff --git a/locales/fr/common.json b/locales/fr/common.json index a4d93373..187b54ae 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -434,7 +434,9 @@ "confirm_button": "Confirmer", "cancel": "Annuler", "success_http": "Page de désabonnement ouverte dans un nouvel onglet", - "success_mailto": "Demande de désabonnement envoyée à votre client mail", + "success_mailto": "E-mail de désabonnement envoyé", + "confirm_message_http": "La page de désabonnement s'ouvrira dans un nouvel onglet.", + "confirm_message_mailto": "Un e-mail de désabonnement sera envoyé à l'expéditeur.", "error": "Impossible de se désabonner", "dismiss": "Ignorer" }, diff --git a/locales/he/common.json b/locales/he/common.json index 64b26d91..b8186b7d 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -385,7 +385,9 @@ "confirm_button": "אשר", "cancel": "לְבַטֵל", "success_http": "דף ביטול הרשמה נפתח בכרטיסייה חדשה", - "success_mailto": "בקשת ביטול הרשמה נשלחה ללקוח הדוא\"ל שלך", + "success_mailto": "אימייל ביטול ההרשמה נשלח", + "confirm_message_http": "דף ביטול ההרשמה ייפתח בכרטיסייה חדשה.", + "confirm_message_mailto": "אימייל ביטול הרשמה יישלח לשולח.", "error": "לא ניתן לבטל את המנוי", "dismiss": "לְפַטֵר" }, diff --git a/locales/hu/common.json b/locales/hu/common.json index 6568903f..744402a1 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -461,7 +461,9 @@ "confirm_button": "Megerősítés", "cancel": "Mégse", "success_http": "Leiratkozási oldal megnyílt egy új lapon", - "success_mailto": "Leiratkozási kérelem elküldve az e-mail kliensnek", + "success_mailto": "Leiratkozó e-mail elküldve", + "confirm_message_http": "A leiratkozási oldal új lapon nyílik meg.", + "confirm_message_mailto": "Leiratkozó e-mailt küldünk a feladónak.", "error": "Nem sikerült leiratkozni", "dismiss": "Elutasítás" }, diff --git a/locales/it/common.json b/locales/it/common.json index a071bfdf..e5768cde 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -434,7 +434,9 @@ "confirm_button": "Conferma", "cancel": "Annulla", "success_http": "Pagina di annullamento iscrizione aperta in una nuova scheda", - "success_mailto": "Richiesta di annullamento iscrizione inviata al tuo client email", + "success_mailto": "Email di disiscrizione inviata", + "confirm_message_http": "La pagina di disiscrizione si aprirà in una nuova scheda.", + "confirm_message_mailto": "Verrà inviata un'email di disiscrizione al mittente.", "error": "Impossibile annullare l'iscrizione", "dismiss": "Ignora" }, diff --git a/locales/ja/common.json b/locales/ja/common.json index ddb4a859..46f07087 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -434,7 +434,9 @@ "confirm_button": "確認", "cancel": "キャンセル", "success_http": "購読解除ページを新しいタブで開きました", - "success_mailto": "購読解除リクエストをメールクライアントに送信しました", + "success_mailto": "配信停止メールを送信しました", + "confirm_message_http": "配信停止ページを新しいタブで開きます。", + "confirm_message_mailto": "送信者に配信停止メールを送信します。", "error": "購読解除できませんでした", "dismiss": "閉じる" }, diff --git a/locales/ko/common.json b/locales/ko/common.json index 748ffe9a..583d99a5 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -436,7 +436,9 @@ "confirm_button": "확인", "cancel": "취소", "success_http": "새 탭에서 구독 취소 페이지가 열렸어요", - "success_mailto": "이메일 클라이언트를 통해 구독 취소 요청이 전송되었어요", + "success_mailto": "수신 거부 이메일을 보냈습니다", + "confirm_message_http": "수신 거부 페이지가 새 탭에서 열립니다.", + "confirm_message_mailto": "발신자에게 수신 거부 이메일을 보냅니다.", "error": "구독을 취소할 수 없어요", "dismiss": "닫기" }, diff --git a/locales/lv/common.json b/locales/lv/common.json index c05be639..42912bbe 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -436,7 +436,9 @@ "confirm_button": "Apstiprināt", "cancel": "Atcelt", "success_http": "Atteikšanās lapa atvērta jaunā cilnē", - "success_mailto": "Atteikšanās pieprasījums nosūtīts", + "success_mailto": "Atrakstīšanās e-pasts nosūtīts", + "confirm_message_http": "Atrakstīšanās lapa tiks atvērta jaunā cilnē.", + "confirm_message_mailto": "Sūtītājam tiks nosūtīts atrakstīšanās e-pasts.", "error": "Neizdevās atteikties", "dismiss": "Aizvērt" }, diff --git a/locales/nl/common.json b/locales/nl/common.json index 68609d29..2532201e 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -434,7 +434,9 @@ "confirm_button": "Bevestigen", "cancel": "Annuleren", "success_http": "Uitschrijfpagina geopend in nieuw tabblad", - "success_mailto": "Uitschrijfverzoek verzonden naar je e-mailclient", + "success_mailto": "Afmeldingsmail verzonden", + "confirm_message_http": "De afmeldpagina wordt in een nieuw tabblad geopend.", + "confirm_message_mailto": "Er wordt een afmeldingsmail naar de afzender gestuurd.", "error": "Kan niet uitschrijven", "dismiss": "Sluiten" }, diff --git a/locales/pl/common.json b/locales/pl/common.json index 0cfa6562..cc7308db 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -436,7 +436,9 @@ "confirm_button": "Potwierdź", "cancel": "Anuluj", "success_http": "Strona wypisania otwarta w nowej karcie", - "success_mailto": "Żądanie wypisania wysłane do klienta poczty", + "success_mailto": "Wysłano e-mail rezygnacji z subskrypcji", + "confirm_message_http": "Strona rezygnacji z subskrypcji otworzy się w nowej karcie.", + "confirm_message_mailto": "Do nadawcy zostanie wysłany e-mail rezygnacji z subskrypcji.", "error": "Nie można się wypisać", "dismiss": "Zamknij" }, diff --git a/locales/pt/common.json b/locales/pt/common.json index 34b8b818..548a42ab 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -434,7 +434,9 @@ "confirm_button": "Confirmar", "cancel": "Cancelar", "success_http": "Página de cancelamento aberta em nova aba", - "success_mailto": "Solicitação de cancelamento enviada para seu cliente de e-mail", + "success_mailto": "E-mail de cancelamento enviado", + "confirm_message_http": "A página de cancelamento será aberta em uma nova aba.", + "confirm_message_mailto": "Um e-mail de cancelamento será enviado ao remetente.", "error": "Não foi possível cancelar a inscrição", "dismiss": "Dispensar" }, diff --git a/locales/ro/common.json b/locales/ro/common.json index 3266fa51..82dfa1fe 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -461,7 +461,9 @@ "confirm_button": "Confirmare", "cancel": "Anulează", "success_http": "Pagina de dezabonare se deschide într-o filă nouă", - "success_mailto": "Cerere de dezabonare trimisă către clientul dvs. de e-mail", + "success_mailto": "E-mailul de dezabonare a fost trimis", + "confirm_message_http": "Pagina de dezabonare se va deschide într-o filă nouă.", + "confirm_message_mailto": "Un e-mail de dezabonare va fi trimis expeditorului.", "error": "Nu se poate dezabona", "dismiss": "Ignoră" }, diff --git a/locales/ru/common.json b/locales/ru/common.json index dbdf5ff6..8908d08e 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -436,7 +436,9 @@ "confirm_button": "Подтвердить", "cancel": "Отмена", "success_http": "Страница отписки открыта в новой вкладке", - "success_mailto": "Запрос на отписку отправлен в ваш почтовый клиент", + "success_mailto": "Письмо для отписки отправлено", + "confirm_message_http": "Страница отписки откроется в новой вкладке.", + "confirm_message_mailto": "Отправителю будет отправлено письмо для отписки.", "error": "Не удалось отписаться", "dismiss": "Закрыть" }, diff --git a/locales/sk/common.json b/locales/sk/common.json index 53b05303..699a2418 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -461,7 +461,9 @@ "confirm_button": "Potvrdiť", "cancel": "Zrušiť", "success_http": "Stránka pre odhlásenie bola otvorená v novej karte", - "success_mailto": "Požiadavka na odhlásenie bola odoslaná do e-mailového klienta", + "success_mailto": "E-mail na odhlásenie bol odoslaný", + "confirm_message_http": "Stránka odhlásenia sa otvorí na novej karte.", + "confirm_message_mailto": "Odosielateľovi bude odoslaný e-mail na odhlásenie.", "error": "Odber sa nedá odhlásiť", "dismiss": "Zavrieť" }, diff --git a/locales/tr/common.json b/locales/tr/common.json index c9d34c9e..3df650c3 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -436,7 +436,9 @@ "confirm_button": "Onayla", "cancel": "İptal", "success_http": "Abonelik iptali sayfası yeni sekmede açıldı", - "success_mailto": "Abonelik iptali isteği e-posta istemcinize gönderildi", + "success_mailto": "Abonelikten çıkma e-postası gönderildi", + "confirm_message_http": "Abonelikten çıkma sayfası yeni sekmede açılacak.", + "confirm_message_mailto": "Gönderene abonelikten çıkma e-postası gönderilecek.", "error": "Abonelik iptal edilemedi", "dismiss": "Kapat" }, diff --git a/locales/uk/common.json b/locales/uk/common.json index af31d3b5..c9971ce8 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -436,7 +436,9 @@ "confirm_button": "Підтвердити", "cancel": "Скасувати", "success_http": "Сторінка скасування підписки відкрилася в новій вкладці", - "success_mailto": "Запит на скасування підписки надіслано на ваш поштовий клієнт", + "success_mailto": "Лист для відписки надіслано", + "confirm_message_http": "Сторінка відписки відкриється в новій вкладці.", + "confirm_message_mailto": "Відправнику буде надіслано лист для відписки.", "error": "Неможливо скасувати підписку", "dismiss": "Відхилити" }, diff --git a/locales/zh/common.json b/locales/zh/common.json index 0f1cf3a4..5821fc96 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -436,7 +436,9 @@ "confirm_button": "确认", "cancel": "取消", "success_http": "取消订阅页面已在新标签页中打开", - "success_mailto": "取消订阅请求已发送", + "success_mailto": "退订邮件已发送", + "confirm_message_http": "退订页面将在新标签页中打开。", + "confirm_message_mailto": "将向发件人发送退订邮件。", "error": "无法取消订阅", "dismiss": "关闭" },