mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
fix: send a mailto unsubscribe from the own account and only to one decoded address
This commit is contained in:
@@ -15,10 +15,10 @@ import { SubmissionQuotaError } from '../../api/jmap-result';
|
||||
import { sendFailureMessage } from '../../lib/send-failure-message';
|
||||
import {
|
||||
isValidUnsubscribeUrl, parseUnsubscribeMailto, isOneClickUnsubscribe, unsubscribeConfirmMessage,
|
||||
unsubscribeSendFolders,
|
||||
} from '../../lib/unsubscribe';
|
||||
import type { ListHeaders } from '../../lib/email-headers';
|
||||
import { findReceivingIdentity } from '../../lib/email-headers';
|
||||
import { mailboxesOfAccount } from '../../lib/mailbox-tree';
|
||||
|
||||
const DISMISSED_KEY = 'webmail:unsubscribe-dismissed:v1';
|
||||
const dismissed = new Set<string>();
|
||||
@@ -47,16 +47,18 @@ interface Props {
|
||||
list: ListHeaders;
|
||||
/** Dedupe key for dismissals (bare Message-ID, else the JMAP id). */
|
||||
messageKey: string;
|
||||
/** The message's JMAP account. A mailto unsubscribe still goes out from the user's own. */
|
||||
jmapAccountId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* List-Unsubscribe (RFC 2369 / RFC 8058) banner. An https one-click URL is
|
||||
* POSTed directly; a plain http(s) URL opens in the in-app browser; a
|
||||
* mailto: URL is sent through the account itself (handing it to the OS mail
|
||||
* app goes nowhere for most users). Dismissals are remembered per Message-ID.
|
||||
* mailto: URL is sent through the user's own account (handing it to the OS
|
||||
* mail app goes nowhere for most users). Dismissals are remembered per
|
||||
* Message-ID.
|
||||
*/
|
||||
export function UnsubscribeBanner({ email, list, messageKey, jmapAccountId }: Props) {
|
||||
export function UnsubscribeBanner({ email, list, messageKey }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
@@ -110,13 +112,12 @@ export function UnsubscribeBanner({ email, list, messageKey, jmapAccountId }: Pr
|
||||
} else {
|
||||
const fields = mailtoFields;
|
||||
if (!fields) throw new Error('invalid mailto');
|
||||
// Sent from the user's own account with one of its identities, also
|
||||
// for a message in a shared account (see unsubscribeSendFolders).
|
||||
const identity = findReceivingIdentity(identities, email) ?? identities[0];
|
||||
if (!identity) throw new Error(t('email_viewer.unsubscribe_banner.no_identity', 'No sending identity available'));
|
||||
// Sent/Drafts of the account the mail is submitted from: the message's.
|
||||
const scoped = mailboxesOfAccount(mailboxes, jmapAccountId);
|
||||
const sent = scoped.find((m) => m.role === 'sent');
|
||||
const drafts = scoped.find((m) => m.role === 'drafts');
|
||||
if (!sent) throw new Error(t('email_composer.no_sent_folder', 'No Sent folder found'));
|
||||
const { sentId, draftsId } = unsubscribeSendFolders(mailboxes);
|
||||
if (!sentId) throw new Error(t('email_composer.no_sent_folder', 'No Sent folder found'));
|
||||
await sendEmail(
|
||||
{
|
||||
from: [{ name: identity.name, email: identity.email }],
|
||||
@@ -125,9 +126,9 @@ export function UnsubscribeBanner({ email, list, messageKey, jmapAccountId }: Pr
|
||||
textBody: fields.body ?? '',
|
||||
},
|
||||
identity.id,
|
||||
sent.originalId ?? sent.id,
|
||||
sentId,
|
||||
undefined,
|
||||
{ draftsMailboxId: drafts ? (drafts.originalId ?? drafts.id) : undefined, accountId: jmapAccountId },
|
||||
{ draftsMailboxId: draftsId },
|
||||
);
|
||||
void pruneEmailSubmissions();
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseUnsubscribeUrls, isValidUnsubscribeUrl, parseMailtoUrl, isOneClickUnsubscribe,
|
||||
parseUnsubscribeMailto, unsubscribeConfirmMessage,
|
||||
parseUnsubscribeMailto, unsubscribeConfirmMessage, unsubscribeSendFolders,
|
||||
} from '../unsubscribe';
|
||||
import type { Mailbox } from '../../api/types';
|
||||
|
||||
describe('parseUnsubscribeUrls', () => {
|
||||
it('prefers http over mailto and validates both', () => {
|
||||
@@ -56,6 +57,10 @@ describe('parseUnsubscribeMailto', () => {
|
||||
|
||||
it('refuses a list of addresses or no address in the address part', () => {
|
||||
expect(parseUnsubscribeMailto('mailto:a@list.example,b@corp.example')).toBeNull();
|
||||
// Encoded separators decode into a second recipient.
|
||||
expect(parseUnsubscribeMailto('mailto:a%2Cb@x.com')).toBeNull();
|
||||
expect(parseUnsubscribeMailto('mailto:a%3Bb@x.com')).toBeNull();
|
||||
expect(parseUnsubscribeMailto('mailto:%22a%2Cb%22@x.com')).toBeNull();
|
||||
expect(parseUnsubscribeMailto('mailto:?to=a@list.example')).toBeNull();
|
||||
expect(parseUnsubscribeMailto('mailto:?cc=a@list.example')).toBeNull();
|
||||
});
|
||||
@@ -80,3 +85,25 @@ describe('unsubscribeConfirmMessage', () => {
|
||||
expect(unsubscribeConfirmMessage('Intro', null)).toBe('Intro');
|
||||
});
|
||||
});
|
||||
|
||||
describe('unsubscribeSendFolders', () => {
|
||||
const mb = (id: string, role: string, shared?: string): Mailbox => ({
|
||||
id: shared ? `${shared}:${id}` : id,
|
||||
name: role,
|
||||
role,
|
||||
totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0,
|
||||
myRights: {} as Mailbox['myRights'],
|
||||
...(shared ? { originalId: id, accountId: shared, isShared: true } : { accountId: 'own', isShared: false }),
|
||||
});
|
||||
|
||||
// The identities the app holds are the own account's, so the mail is
|
||||
// created there too, also for a newsletter that reached a team mailbox.
|
||||
it("uses the own account's Sent and Drafts even when a shared account's come first", () => {
|
||||
const folders = unsubscribeSendFolders([
|
||||
mb('t-sent', 'sent', 'team'), mb('t-drafts', 'drafts', 'team'),
|
||||
mb('sent', 'sent'), mb('drafts', 'drafts'),
|
||||
]);
|
||||
expect(folders).toEqual({ sentId: 'sent', draftsId: 'drafts' });
|
||||
expect(unsubscribeSendFolders([mb('t-sent', 'sent', 'team')])).toEqual({ sentId: undefined, draftsId: undefined });
|
||||
});
|
||||
});
|
||||
|
||||
+25
-1
@@ -1,6 +1,9 @@
|
||||
// List-Unsubscribe (RFC 2369 / RFC 8058) and mailto: parsing. Port of the
|
||||
// relevant parts of the webmail's `lib/validation.ts`.
|
||||
|
||||
import type { Mailbox } from '../api/types';
|
||||
import { mailboxesOfAccount } from './mailbox-tree';
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export function isValidEmail(value: string): boolean {
|
||||
@@ -151,7 +154,10 @@ export function parseUnsubscribeMailto(url: string): UnsubscribeMailto | null {
|
||||
} catch {
|
||||
to = addresses[0].trim();
|
||||
}
|
||||
if (!isValidEmail(to)) return null;
|
||||
// One plain address after decoding too: `a%2Cb@x.example` is two
|
||||
// recipients to the server, and a quote, bracket or separator has no place
|
||||
// in an unsubscribe address.
|
||||
if (!isValidEmail(to) || /[,;<>"()\\]/.test(to)) return null;
|
||||
|
||||
const subject = parsed.subject?.replace(/[\r\n]+/g, ' ').trim().slice(0, UNSUBSCRIBE_SUBJECT_MAX) || undefined;
|
||||
const body = parsed.body?.slice(0, UNSUBSCRIBE_BODY_MAX) || undefined;
|
||||
@@ -169,3 +175,21 @@ export function unsubscribeConfirmMessage(intro: string, fields: UnsubscribeMail
|
||||
if (fields.body) lines.push('', fields.body);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a mailto unsubscribe is created and submitted: the user's own
|
||||
* account, whose identities are the ones the app holds, with its Sent and
|
||||
* Drafts (raw ids). Like the webmail, which sends it with the signed-in
|
||||
* client and the identity that received the message. Creating it in a
|
||||
* shared account's folders with an identity of the user's own account made
|
||||
* the submission name an identity that account doesn't have.
|
||||
*/
|
||||
export function unsubscribeSendFolders(mailboxes: Mailbox[]): { sentId?: string; draftsId?: string } {
|
||||
const own = mailboxesOfAccount(mailboxes, undefined);
|
||||
const sent = own.find((m) => m.role === 'sent');
|
||||
const drafts = own.find((m) => m.role === 'drafts');
|
||||
return {
|
||||
sentId: sent ? (sent.originalId ?? sent.id) : undefined,
|
||||
draftsId: drafts ? (drafts.originalId ?? drafts.id) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user