Allow for sending announcement mails with List-Unsubscribe:

Per https://datatracker.ietf.org/doc/html/rfc8058,
we send "announcement" mails with
  List-Unsubscribe: <${apiUrl}/unsubscribe/${userId}/${oneClickUnsubscribeToken}>
  List-Unsubscribe-Post: List-Unsubscribe=One-Click
and handle
  POST /api/unsubscribe/:user/:token => this unsubscribes
  GET  /api/unsubscribe/:user/:token => 302 /unsubscribe/:user/:token
  GET  /unsubscribe/:user/:token     => user-visible page with clickthrough confirmation

In this configuration, compatible MUAs will show an "unsubscribe" button
that, when clicked, will POST to the URL directly

Less-compatible MUAs (and scanners) will open the page directly
which will redirect to a click-though; interactive users will be able to
unsubscribe, scanners won't unsubscribe by accident

Nothing /actually/ sends non-reactive mails,
so this is never used at this time

Closes #854
This commit is contained in:
наб 2025-07-13 17:47:31 +02:00
parent 3cffd4a537
commit c6e4c9294f
No known key found for this signature in database
GPG key ID: BCFD0B018D2658F1
7 changed files with 162 additions and 3 deletions

View file

@ -6,14 +6,16 @@
import { URLSearchParams } from 'node:url';
import * as nodemailer from 'nodemailer';
import juice from 'juice';
import { nanoid } from 'nanoid';
import { Inject, Injectable } from '@nestjs/common';
import { validate as validateEmail } from 'deep-email-validator';
import { UtilityService } from '@/core/UtilityService.js';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import type Logger from '@/logger.js';
import type { MiMeta, UserProfilesRepository } from '@/models/_.js';
import type { MiMeta, MiUserProfile, UserProfilesRepository } from '@/models/_.js';
import { LoggerService } from '@/core/LoggerService.js';
import { CacheService } from '@/core/CacheService.js';
import { bindThis } from '@/decorators.js';
import { HttpRequestService } from '@/core/HttpRequestService.js';
@ -34,12 +36,13 @@ export class EmailService {
private loggerService: LoggerService,
private utilityService: UtilityService,
private httpRequestService: HttpRequestService,
private cacheService: CacheService,
) {
this.logger = this.loggerService.getLogger('email');
}
@bindThis
public async sendEmail(to: string, subject: string, html: string, text: string) {
public async sendEmail(to: string, subject: string, html: string, text: string, opts?: { announcementFor?: MiUserProfile } | undefined) {
if (!this.meta.enableEmail) return;
const iconUrl = `${this.config.url}/static-assets/mi-white.png`;
@ -142,6 +145,19 @@ export class EmailService {
const inlinedHtml = juice(htmlContent);
const headers: any = {};
if (opts && opts.announcementFor) {
const { userId } = opts.announcementFor;
let { oneClickUnsubscribeToken } = opts.announcementFor;
if (!oneClickUnsubscribeToken) {
oneClickUnsubscribeToken = nanoid();
await this.userProfilesRepository.update({ userId }, { oneClickUnsubscribeToken });
await this.cacheService.userProfileCache.delete(userId);
}
headers['List-Unsubscribe'] = `<${this.config.apiUrl}/unsubscribe/${userId}/${oneClickUnsubscribeToken}>`;
headers['List-Unsubscribe-Post'] = 'List-Unsubscribe=One-Click';
}
try {
// TODO: htmlサニタイズ
const info = await transporter.sendMail({
@ -150,6 +166,7 @@ export class EmailService {
subject: subject,
text: text,
html: inlinedHtml,
headers: headers,
});
this.logger.info(`Message sent: ${info.messageId}`);