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
@@ -1,7 +1,8 @@
/**
* The forwarding addresses through their repository: a person's own address
* and a trip's are told apart, renewing revokes instead of deleting so the old
* token is still recognised, and a mail notes the address it came in on.
* token is still recognised, a mail notes the address it came in on, and the
* people who hold a live address are named for the pause notice.
*/
import { MailIngestAddresses } from '../../../../src/db/entities/MailIngestAddresses.entity';
import type { MailIngestAddressesRepository } from '../../../../src/db/repositories/MailIngestAddresses.repository';
@@ -93,4 +94,17 @@ describe('MailIngestAddressesRepository', () => {
await addresses.touchUsed(id, LATER);
expect((await addresses.findById(id))?.last_used_at).toBe(LATER);
});
it('names each person who holds an address that is not revoked, once', async () => {
const { user: mia } = await makeUser(t);
const { user: noah } = await makeUser(t);
const trip = await makeTrip(t, mia.id);
await addresses.insertAddress({ userId: mia.id, tripId: null, tokenHash: 'm1', tokenEnc: 'e', createdAt: AT });
await addresses.insertAddress({ userId: mia.id, tripId: trip.id, tokenHash: 'm2', tokenEnc: 'e', createdAt: AT });
await addresses.insertAddress({ userId: noah.id, tripId: null, tokenHash: 'n1', tokenEnc: 'e', createdAt: AT });
expect(await addresses.listHolders()).toEqual([mia.id, noah.id].sort((a, b) => a - b));
await addresses.revokeLive(noah.id, null, LATER);
expect(await addresses.listHolders()).toEqual([mia.id]);
});
});
@@ -58,6 +58,9 @@ describe('MailIngestCursorRepository', () => {
await cursor.recordOk('imap', 'INBOX', T1);
row = await cursor.find('imap', 'INBOX');
expect(row).toMatchObject({ uid_validity: 7, last_uid: 42, last_ok_at: T1, last_error: null });
await cursor.forgetOk('imap', 'INBOX');
expect(await cursor.find('imap', 'INBOX')).toMatchObject({ uid_validity: 7, last_ok_at: null });
expect(await cursor.find('cloud', '')).toBeNull();
});
});
@@ -74,6 +74,18 @@ describe('MailIngestNotifyService', () => {
notify.settingsChanged(3);
expect(realtime.broadcastToUser).toHaveBeenCalledWith(3, { type: 'mailInbox:settings' });
});
it('tells a person the import paused, and survives a failed send', async () => {
notifications.send.mockRejectedValue(new Error('smtp down'));
await expect(notify.importPaused(4)).resolves.toBeUndefined();
expect(notifications.send).toHaveBeenCalledWith({
event: 'mail_ingest_paused',
actorId: null,
scope: 'user',
targetId: 4,
params: {},
});
});
});
describe('MailIngestNotificationActions', () => {
@@ -0,0 +1,98 @@
/**
* The pause notice over stubs: when the import stops, every holder of a live
* address with their own import on hears it once; the pause is noted before
* anyone is told, a second run stays quiet, another process holding the
* notice is left alone, and the import coming back forgets the pause so the
* next one is announced again.
*/
import { MailIngestAddresses } from '../../../../src/db/entities/MailIngestAddresses.entity';
import { MailIngestCursor } from '../../../../src/db/entities/MailIngestCursor.entity';
import { MailIngestNotifyService } from '../../../../src/nest/mail-ingest/mail-ingest-notify.service';
import { MailIngestPauseJob } from '../../../../src/nest/mail-ingest/mail-ingest-pause.job';
import { MailIngestSettingsService } from '../../../../src/nest/mail-ingest/mail-ingest-settings.service';
import { CronRegistrarService } from '../../../../src/nest/scheduling/cron-registrar.service';
import { getRepositoryToken } from '@mikro-orm/nestjs';
import { Test } from '@nestjs/testing';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../../../src/nest/audit/audit-log.logger', () => ({
LOG_LEVEL: 'error',
logInfo: vi.fn(),
logDebug: vi.fn(),
logError: vi.fn(),
logWarn: vi.fn(),
}));
const NOW = new Date('2030-01-15T10:00:00Z');
const settings = {
isAvailable: vi.fn(async () => false),
prefsOf: vi.fn(async (userId: number) => ({ enabled: userId !== 3, ai_enabled: true, auto_add: true })),
};
const cursor = {
find: vi.fn(),
acquireLease: vi.fn(async () => true),
releaseLease: vi.fn(async () => undefined),
recordOk: vi.fn(async () => undefined),
forgetOk: vi.fn(async () => undefined),
};
const addresses = { listHolders: vi.fn(async () => [1, 2, 3]) };
const notify = { importPaused: vi.fn(async () => undefined) };
const registrar = { isEnabled: () => false, register: vi.fn() };
let job: MailIngestPauseJob;
beforeEach(async () => {
vi.clearAllMocks();
settings.isAvailable.mockResolvedValue(false);
cursor.find.mockResolvedValue(null);
cursor.acquireLease.mockResolvedValue(true);
const moduleRef = await Test.createTestingModule({
providers: [
MailIngestPauseJob,
{ provide: MailIngestSettingsService, useValue: settings },
{ provide: MailIngestNotifyService, useValue: notify },
{ provide: CronRegistrarService, useValue: registrar },
{ provide: getRepositoryToken(MailIngestCursor), useValue: cursor },
{ provide: getRepositoryToken(MailIngestAddresses), useValue: addresses },
],
}).compile();
job = moduleRef.get(MailIngestPauseJob);
});
describe('the pause notice', () => {
it('tells each holder of a live address with their import on, once, after noting the pause', async () => {
expect(await job.tick(NOW)).toBe(2);
expect(notify.importPaused.mock.calls).toEqual([[1], [2]]);
expect(cursor.recordOk).toHaveBeenCalledWith('notice', 'paused', '2030-01-15 10:00:00');
expect(cursor.recordOk.mock.invocationCallOrder[0]).toBeLessThan(notify.importPaused.mock.invocationCallOrder[0]);
expect(cursor.releaseLease).toHaveBeenCalled();
cursor.find.mockResolvedValue({ last_ok_at: '2030-01-15 10:00:00' });
expect(await job.tick(NOW)).toBe(0);
expect(notify.importPaused).toHaveBeenCalledTimes(2);
});
it('leaves the notice to the process that holds it', async () => {
cursor.acquireLease.mockResolvedValue(false);
expect(await job.tick(NOW)).toBe(0);
expect(addresses.listHolders).not.toHaveBeenCalled();
});
it('forgets the pause once the import is back, and tells nobody while it runs', async () => {
settings.isAvailable.mockResolvedValue(true);
expect(await job.tick(NOW)).toBe(0);
expect(cursor.forgetOk).not.toHaveBeenCalled();
cursor.find.mockResolvedValue({ last_ok_at: '2030-01-15 10:00:00' });
expect(await job.tick(NOW)).toBe(0);
expect(cursor.forgetOk).toHaveBeenCalledWith('notice', 'paused');
expect(notify.importPaused).not.toHaveBeenCalled();
expect(cursor.acquireLease).not.toHaveBeenCalled();
});
it('logs a failed run and gives the lease back', async () => {
addresses.listHolders.mockRejectedValueOnce(new Error('db gone'));
expect(await job.tick(NOW)).toBe(0);
expect(cursor.recordOk).not.toHaveBeenCalled();
expect(cursor.releaseLease).toHaveBeenCalledWith('notice', 'paused', expect.any(String));
});
});
@@ -501,5 +501,6 @@ describe('the mail import’s events follow its switch', () => {
const { event_types } = await svc.getPreferencesMatrix(user.id, 'user');
expect(event_types).toContain('mail_booking_added');
expect(event_types).toContain('mail_booking_pending');
expect(event_types).toContain('mail_ingest_paused');
});
});
@@ -652,6 +652,7 @@ describe('send() — in-app notification content', () => {
targetId: user.id,
params: { count: '2' },
});
await send({ event: 'mail_ingest_paused', actorId: null, scope: 'user', targetId: user.id, params: {} });
const notifs = await getInAppNotifications(user.id);
expect(notifs.map((n) => [n.text_key, n.navigate_target])).toEqual(
@@ -659,6 +660,7 @@ describe('send() — in-app notification content', () => {
['notif.mail_booking_added.text', '/trips/7?tab=bookings'],
['notif.mail_booking_added.shared', '/trips/7?tab=bookings'],
['notif.mail_booking_pending.text', '/dashboard?inbox=mail'],
['notif.mail_ingest_paused.text', null],
]),
);
});