feat(mail-ingest): tell every address holder once when the import stops

A plan without the import switches it off from outside the server, and an
admin can switch it off too; the plan promises a "mail import paused" note.
An hourly job sends mail_ingest_paused to each holder of a live address with
their own import on, notes the pause first, and forgets it once it is back.
This commit is contained in:
Maurice
2026-11-13 17:11:30 +01:00
parent 1d052f725f
commit 44f669cd0e
72 changed files with 429 additions and 3 deletions
@@ -94,6 +94,18 @@ export class MailIngestAddressesRepository extends TrekRepository<MailIngestAddr
return Number(result.numUpdatedRows);
}
/** The people who hold at least one address that is not revoked. */
async listHolders(): Promise<number[]> {
const rows = await this.db()
.selectFrom('mail_ingest_addresses')
.select('user_id')
.distinct()
.where('revoked_at', 'is', null)
.orderBy('user_id')
.execute();
return rows.map((row) => row.user_id);
}
/** Notes that a mail came in on the address. */
async touchUsed(id: number, at: DbTimestamp): Promise<void> {
await this.db().updateTable('mail_ingest_addresses').set({ last_used_at: at }).where('id', '=', id).execute();
@@ -4,8 +4,12 @@ import type { DbTimestamp } from '../types';
import type { AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
/** What reads mail: an IMAP box, the TREK Cloud relay, or the processing pipeline itself. */
type MailIngestCursorTransport = 'imap' | 'cloud' | 'pipeline';
/**
* What reads mail: an IMAP box, the TREK Cloud relay, or the processing
* pipeline itself; and the pause notice, whose good run is the pause it
* announced.
*/
type MailIngestCursorTransport = 'imap' | 'cloud' | 'pipeline' | 'notice';
/** One `mail_ingest_cursor` row as stored. */
interface MailIngestCursorRow {
@@ -102,6 +106,16 @@ export class MailIngestCursorRepository extends TrekRepository<MailIngestCursor>
.execute();
}
/** Forgets the last good run, as if the reader had never run. */
async forgetOk(transport: MailIngestCursorTransport, mailbox: string): Promise<void> {
await this.db()
.updateTable('mail_ingest_cursor')
.set({ last_ok_at: null })
.where('transport', '=', transport)
.where('mailbox', '=', mailbox)
.execute();
}
/** A failed run, for the admin's status card; the position stays where the last good run left it. */
async recordError(transport: MailIngestCursorTransport, mailbox: string, error: string): Promise<void> {
await this.db()
@@ -75,6 +75,19 @@ export class MailIngestNotifyService {
);
}
/** @txStandalone the import stopped; the person's address takes no mail until it is back. */
async importPaused(userId: number): Promise<void> {
await this.quietly(
this.notifications.send({
event: 'mail_ingest_paused',
actorId: null,
scope: 'user',
targetId: userId,
params: {},
}),
);
}
/** @txStandalone one note for the bookings of a mail that wait in the inbox. */
async bookingsPending(userId: number, pending: number): Promise<void> {
this.inboxChanged(userId, pending);
@@ -0,0 +1,74 @@
import { MailIngestAddresses } from '../../db/entities/MailIngestAddresses.entity';
import { MailIngestCursor } from '../../db/entities/MailIngestCursor.entity';
import type { MailIngestAddressesRepository } from '../../db/repositories/MailIngestAddresses.repository';
import type { MailIngestCursorRepository } from '../../db/repositories/MailIngestCursor.repository';
import { dbNow } from '../../db/types';
import { logError } from '../audit/audit-log.logger';
import { CronRegistrarService, LEASE_OWNER } from '../scheduling/cron-registrar.service';
import { MailIngestNotifyService } from './mail-ingest-notify.service';
import { MailIngestSettingsService } from './mail-ingest-settings.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable, type OnApplicationBootstrap } from '@nestjs/common';
/** How long a run holds the notice before another process may take it over. */
const LEASE_MS = 10 * 60 * 1000;
/** The notice's row in the readers' cursor table: transport `notice`, this as its mailbox. */
const PAUSED = 'paused';
/**
* Tells every person who holds a forwarding address, once, that the import
* stopped: on the TREK Cloud a plan without it switches it off from outside
* the server, and an admin can switch it off in the dialog. Checked every
* hour. The notice's cursor row notes the pause it announced, and forgets it
* once the import is back, so the next pause is announced again. A person
* who switched their own import off hears nothing, having nothing to lose.
*/
@Injectable()
export class MailIngestPauseJob implements OnApplicationBootstrap {
constructor(
@InjectRepository(MailIngestCursor) private readonly cursor: MailIngestCursorRepository,
@InjectRepository(MailIngestAddresses) private readonly addresses: MailIngestAddressesRepository,
private readonly settings: MailIngestSettingsService,
private readonly notify: MailIngestNotifyService,
private readonly registrar: CronRegistrarService,
) {}
onApplicationBootstrap(): void {
if (!this.registrar.isEnabled()) return;
this.registrar.register('mail-ingest-pause-notice', '25 * * * *', () => this.tick());
}
/**
* One run; answers how many people it told.
*
* @txIndependent the pause is noted before anyone is told, so a run that
* dies halfway never tells anyone twice; each notice stands on its own.
*/
async tick(now: Date = new Date()): Promise<number> {
if (await this.settings.isAvailable()) {
if ((await this.cursor.find('notice', PAUSED))?.last_ok_at) await this.cursor.forgetOk('notice', PAUSED);
return 0;
}
const until = dbNow(new Date(now.getTime() + LEASE_MS));
if (!(await this.cursor.acquireLease('notice', PAUSED, LEASE_OWNER, until, dbNow(now)))) return 0;
try {
if ((await this.cursor.find('notice', PAUSED))?.last_ok_at) return 0;
const people = await this.peopleToTell();
await this.cursor.recordOk('notice', PAUSED, dbNow(now));
await Promise.all(people.map((userId) => this.notify.importPaused(userId)));
return people.length;
} catch (err) {
logError(`[mail-ingest] The pause notice failed: ${err instanceof Error ? err.message : String(err)}`);
return 0;
} finally {
await this.cursor.releaseLease('notice', PAUSED, LEASE_OWNER);
}
}
/** The holders of a live address who have their own import on. */
private async peopleToTell(): Promise<number[]> {
const holders = await this.addresses.listHolders();
const enabled = await Promise.all(holders.map(async (userId) => (await this.settings.prefsOf(userId)).enabled));
return holders.filter((_, i) => enabled[i]);
}
}
@@ -30,6 +30,7 @@ import { MailIngestInboxService } from './mail-ingest-inbox.service';
import { MailIngestIntakeService } from './mail-ingest-intake.service';
import { MailIngestNotificationActions } from './mail-ingest-notification-actions';
import { MailIngestNotifyService } from './mail-ingest-notify.service';
import { MailIngestPauseJob } from './mail-ingest-pause.job';
import { MailIngestProcessJob } from './mail-ingest-process.job';
import { MailIngestProcessService } from './mail-ingest-process.service';
import { MailIngestReadingService } from './mail-ingest-reading.service';
@@ -109,6 +110,7 @@ import { Module } from '@nestjs/common';
MailIngestRetentionJob,
MailIngestImapJob,
MailIngestRelayJob,
MailIngestPauseJob,
],
exports: [
MailIngestSettingsService,
@@ -48,6 +48,7 @@ export type NotifEventType =
| 'wanderer_source_hint'
| 'mail_booking_added'
| 'mail_booking_pending'
| 'mail_ingest_paused'
| 'plugin_notification';
/** Every event, in the order the preferences UI lists them. */
@@ -72,6 +73,7 @@ export const ALL_EVENT_TYPES: NotifEventType[] = [
'wanderer_source_hint',
'mail_booking_added',
'mail_booking_pending',
'mail_ingest_paused',
'plugin_notification',
];
@@ -137,6 +137,7 @@ export class NotificationPreferencesService {
return (await resolveAppSetting(this.appSettings, 'flight_live_enabled')) !== 'false';
case 'mail_booking_added':
case 'mail_booking_pending':
case 'mail_ingest_paused':
return (await resolveAppSetting(this.appSettings, 'mail_ingest_enabled')) === 'true';
default:
return true;
@@ -306,6 +306,14 @@ const EVENT_NOTIFICATION_CONFIG: Record<string, EventNotifConfig> = {
navigateTextKey: 'notif.action.review',
navigateTarget: () => '/dashboard?inbox=mail',
},
// The import stopped (a plan without it, or an admin switched it off): every
// person with an address hears it once, with nothing to open.
mail_ingest_paused: {
inAppType: 'simple',
titleKey: 'notif.mail_ingest_paused.title',
textKey: 'notif.mail_ingest_paused.text',
navigateTarget: () => null,
},
synology_session_cleared: {
inAppType: 'simple',
titleKey: 'notifications.synologySessionCleared.title',
@@ -41,6 +41,7 @@ const NTFY_EVENT_META: Partial<Record<NotifEventType, { priority: 1 | 2 | 3 | 4
wanderer_source_hint: { priority: 2, tags: ['hiking_boot'] },
mail_booking_added: { priority: 3, tags: ['envelope'] },
mail_booking_pending: { priority: 2, tags: ['envelope'] },
mail_ingest_paused: { priority: 3, tags: ['pause_button'] },
};
const NTFY_DEFAULT_META = { priority: 3 as const, tags: [] as string[] };