mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +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 { sendFailureMessage } from '../../lib/send-failure-message';
|
||||||
import {
|
import {
|
||||||
isValidUnsubscribeUrl, parseUnsubscribeMailto, isOneClickUnsubscribe, unsubscribeConfirmMessage,
|
isValidUnsubscribeUrl, parseUnsubscribeMailto, isOneClickUnsubscribe, unsubscribeConfirmMessage,
|
||||||
|
unsubscribeSendFolders,
|
||||||
} from '../../lib/unsubscribe';
|
} from '../../lib/unsubscribe';
|
||||||
import type { ListHeaders } from '../../lib/email-headers';
|
import type { ListHeaders } from '../../lib/email-headers';
|
||||||
import { findReceivingIdentity } 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_KEY = 'webmail:unsubscribe-dismissed:v1';
|
||||||
const dismissed = new Set<string>();
|
const dismissed = new Set<string>();
|
||||||
@@ -47,16 +47,18 @@ interface Props {
|
|||||||
list: ListHeaders;
|
list: ListHeaders;
|
||||||
/** Dedupe key for dismissals (bare Message-ID, else the JMAP id). */
|
/** Dedupe key for dismissals (bare Message-ID, else the JMAP id). */
|
||||||
messageKey: string;
|
messageKey: string;
|
||||||
|
/** The message's JMAP account. A mailto unsubscribe still goes out from the user's own. */
|
||||||
jmapAccountId?: string;
|
jmapAccountId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List-Unsubscribe (RFC 2369 / RFC 8058) banner. An https one-click URL is
|
* 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
|
* 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
|
* mailto: URL is sent through the user's own account (handing it to the OS
|
||||||
* app goes nowhere for most users). Dismissals are remembered per Message-ID.
|
* 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 c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const t = useLocaleStore((s) => s.t);
|
const t = useLocaleStore((s) => s.t);
|
||||||
@@ -110,13 +112,12 @@ export function UnsubscribeBanner({ email, list, messageKey, jmapAccountId }: Pr
|
|||||||
} else {
|
} else {
|
||||||
const fields = mailtoFields;
|
const fields = mailtoFields;
|
||||||
if (!fields) throw new Error('invalid mailto');
|
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];
|
const identity = findReceivingIdentity(identities, email) ?? identities[0];
|
||||||
if (!identity) throw new Error(t('email_viewer.unsubscribe_banner.no_identity', 'No sending identity available'));
|
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 { sentId, draftsId } = unsubscribeSendFolders(mailboxes);
|
||||||
const scoped = mailboxesOfAccount(mailboxes, jmapAccountId);
|
if (!sentId) throw new Error(t('email_composer.no_sent_folder', 'No Sent folder found'));
|
||||||
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'));
|
|
||||||
await sendEmail(
|
await sendEmail(
|
||||||
{
|
{
|
||||||
from: [{ name: identity.name, email: identity.email }],
|
from: [{ name: identity.name, email: identity.email }],
|
||||||
@@ -125,9 +126,9 @@ export function UnsubscribeBanner({ email, list, messageKey, jmapAccountId }: Pr
|
|||||||
textBody: fields.body ?? '',
|
textBody: fields.body ?? '',
|
||||||
},
|
},
|
||||||
identity.id,
|
identity.id,
|
||||||
sent.originalId ?? sent.id,
|
sentId,
|
||||||
undefined,
|
undefined,
|
||||||
{ draftsMailboxId: drafts ? (drafts.originalId ?? drafts.id) : undefined, accountId: jmapAccountId },
|
{ draftsMailboxId: draftsId },
|
||||||
);
|
);
|
||||||
void pruneEmailSubmissions();
|
void pruneEmailSubmissions();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import {
|
import {
|
||||||
parseUnsubscribeUrls, isValidUnsubscribeUrl, parseMailtoUrl, isOneClickUnsubscribe,
|
parseUnsubscribeUrls, isValidUnsubscribeUrl, parseMailtoUrl, isOneClickUnsubscribe,
|
||||||
parseUnsubscribeMailto, unsubscribeConfirmMessage,
|
parseUnsubscribeMailto, unsubscribeConfirmMessage, unsubscribeSendFolders,
|
||||||
} from '../unsubscribe';
|
} from '../unsubscribe';
|
||||||
|
import type { Mailbox } from '../../api/types';
|
||||||
|
|
||||||
describe('parseUnsubscribeUrls', () => {
|
describe('parseUnsubscribeUrls', () => {
|
||||||
it('prefers http over mailto and validates both', () => {
|
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', () => {
|
it('refuses a list of addresses or no address in the address part', () => {
|
||||||
expect(parseUnsubscribeMailto('mailto:a@list.example,b@corp.example')).toBeNull();
|
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:?to=a@list.example')).toBeNull();
|
||||||
expect(parseUnsubscribeMailto('mailto:?cc=a@list.example')).toBeNull();
|
expect(parseUnsubscribeMailto('mailto:?cc=a@list.example')).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -80,3 +85,25 @@ describe('unsubscribeConfirmMessage', () => {
|
|||||||
expect(unsubscribeConfirmMessage('Intro', null)).toBe('Intro');
|
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
|
// List-Unsubscribe (RFC 2369 / RFC 8058) and mailto: parsing. Port of the
|
||||||
// relevant parts of the webmail's `lib/validation.ts`.
|
// 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@]+$/;
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
export function isValidEmail(value: string): boolean {
|
export function isValidEmail(value: string): boolean {
|
||||||
@@ -151,7 +154,10 @@ export function parseUnsubscribeMailto(url: string): UnsubscribeMailto | null {
|
|||||||
} catch {
|
} catch {
|
||||||
to = addresses[0].trim();
|
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 subject = parsed.subject?.replace(/[\r\n]+/g, ' ').trim().slice(0, UNSUBSCRIBE_SUBJECT_MAX) || undefined;
|
||||||
const body = parsed.body?.slice(0, UNSUBSCRIBE_BODY_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);
|
if (fields.body) lines.push('', fields.body);
|
||||||
return lines.join('\n');
|
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