Skip to content

Commit 8de91e0

Browse files
sametbasbugclaude
andcommitted
Let Orbit write to the people behind its agents
Announcements and moderation decisions had no way out of the database. An agent could be told; the human responsible for it could not. Outbound mail goes through an outbox. Recipient rows are written in the same batch as the publish, so a published announcement can never be one that nobody was told about, and a separate five-minute cron drains them. The request path has no waitUntil, so sending inline would either block the response or lose the mail; the table doubles as the record of who was notified. Permanent failures are not retried. Writing five more times to an invalid address means five more bounces, and bounces cost us delivery to the addresses that do work. Announcement mail can be turned off from the dashboard. Account, moderation and security notices cannot, and the panel says why. The List-Unsubscribe header goes only on mail that can actually be switched off rather than promising something we do not honour. Staging keeps two cron triggers. The free plan allows five per account and production now needs three; claiming the third here left staging deployed with its schedules half-updated. Staging has no API key, so it had nothing to drain. The config lock now fails the build instead of the deploy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent d93bd1a commit 8de91e0

25 files changed

Lines changed: 1078 additions & 19 deletions
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
-- Giden posta kuyruğu ve kaydı.
2+
--
3+
-- Neden kuyruk, neden doğrudan gönderim değil: bir duyuru yayına
4+
-- alındığında onlarca kişiye posta gidecek. Bunu istek içinde yapmak yayını
5+
-- postanın hızına bağlar; Worker'ın istek yolunda waitUntil yok ve alt-istek
6+
-- sınırları var. Satırlar yayınla AYNI batch'te yazılıyor — yayın
7+
-- başarılıysa gönderilecekler kesin yazılmıştır, yayın düşerse ikisi de
8+
-- düşer. "Yayımlandı ama kimseye haber verilmedi" hâli mümkün değil.
9+
--
10+
-- Tablo aynı zamanda kayıt: kime, ne zaman, hangi konuyla yazıldığı ve
11+
-- sonucu burada duruyor. Bir güvenlik bildiriminin gönderildiğini
12+
-- söyleyebilmek, gönderebilmek kadar önemli.
13+
14+
CREATE TABLE email_deliveries (
15+
id TEXT PRIMARY KEY,
16+
account_id TEXT NOT NULL REFERENCES accounts(id),
17+
-- Adres satıra kopyalanıyor, hesaptan okunmuyor: gönderim anında hangi
18+
-- adrese yazdığımız sonradan değişmemeli. Kullanıcı adresini
19+
-- değiştirdiğinde geçmiş kayıt yalan söylemeye başlardı.
20+
recipient TEXT NOT NULL,
21+
-- 'announcement' tercihe tabidir; 'moderation' ve 'security' değildir.
22+
-- Hesabınla ilgili bir karardan haberdar olmamayı seçemezsin.
23+
kind TEXT NOT NULL CHECK (kind IN ('announcement', 'moderation', 'security')),
24+
subject TEXT NOT NULL,
25+
body_text TEXT NOT NULL,
26+
status TEXT NOT NULL CHECK (status IN ('pending', 'sent', 'failed')),
27+
attempts INTEGER NOT NULL DEFAULT 0,
28+
last_error TEXT,
29+
created_at INTEGER NOT NULL,
30+
sent_at INTEGER,
31+
-- Aynı olayın aynı kişiye iki kez yazılmasını veritabanı engelliyor.
32+
-- Uygulama katmanındaki bir kontrol, iki isteğin yarıştığı anda
33+
-- tutmazdı; burada tutuyor.
34+
subject_ref TEXT NOT NULL,
35+
UNIQUE (account_id, subject_ref)
36+
);
37+
38+
-- Kuyruğu boşaltan işleyicinin tek sorgusu: en eski bekleyenler önce.
39+
CREATE INDEX email_deliveries_pending_idx
40+
ON email_deliveries (status, created_at)
41+
WHERE status = 'pending';
42+
43+
CREATE INDEX email_deliveries_account_idx
44+
ON email_deliveries (account_id, created_at DESC);
45+
46+
-- Duyuru postalarını kapatabilmek yasal bir zorunluluk değil (bunlar hizmet
47+
-- bildirimi, ticari ileti değil) ama kapatamadığı postayı insan spam
48+
-- işaretler; o da gönderim itibarını bozarak GERÇEKTEN kritik bir bildirimi
49+
-- göndermemiz gerektiğinde kimseye ulaşamamamıza yol açar. Anahtar bu yüzden
50+
-- var. Varsayılan açık: haber almak istemediğini söyleyene kadar istiyorsun.
51+
ALTER TABLE accounts ADD COLUMN announcement_emails_enabled INTEGER NOT NULL DEFAULT 1;

‎package.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@
3838
"staging:slice5:images-cpu": "tsx scripts/orbit-slice5-images-cpu-staging-e2e.ts",
3939
"orbit:import:verify": "tsx scripts/orbit-slice3-manifest.ts",
4040
"orbit:import:local": "tsx scripts/orbit-slice3-import.ts --local --database=orbit-v6-local --config=wrangler.test.jsonc",
41-
"test:d1": "tsx --test --test-concurrency=5 scripts/orbit-agent-identity-tests.ts scripts/orbit-api-contract-tests.ts scripts/orbit-d1-tests.ts scripts/orbit-mcp-authorization-tests.ts scripts/orbit-public-page-tests.ts scripts/orbit-public-repository-tests.ts scripts/orbit-single-renderer-tests.ts scripts/orbit-style-scale-tests.ts scripts/orbit-slice1-tests.ts scripts/orbit-slice1-worker-tests.ts scripts/orbit-slice3-tests.ts scripts/orbit-slice4-tests.ts scripts/orbit-slice5-tests.ts",
42-
"test:d1:core": "tsx --test --test-concurrency=5 scripts/orbit-agent-identity-tests.ts scripts/orbit-api-contract-tests.ts scripts/orbit-d1-tests.ts scripts/orbit-public-page-tests.ts scripts/orbit-public-repository-tests.ts scripts/orbit-slice1-tests.ts scripts/orbit-slice1-worker-tests.ts scripts/orbit-slice3-tests.ts",
41+
"test:d1": "tsx --test --test-concurrency=5 scripts/orbit-agent-identity-tests.ts scripts/orbit-api-contract-tests.ts scripts/orbit-d1-tests.ts scripts/orbit-email-tests.ts scripts/orbit-mcp-authorization-tests.ts scripts/orbit-public-page-tests.ts scripts/orbit-public-repository-tests.ts scripts/orbit-single-renderer-tests.ts scripts/orbit-style-scale-tests.ts scripts/orbit-slice1-tests.ts scripts/orbit-slice1-worker-tests.ts scripts/orbit-slice3-tests.ts scripts/orbit-slice4-tests.ts scripts/orbit-slice5-tests.ts",
42+
"test:d1:core": "tsx --test --test-concurrency=5 scripts/orbit-agent-identity-tests.ts scripts/orbit-api-contract-tests.ts scripts/orbit-d1-tests.ts scripts/orbit-email-tests.ts scripts/orbit-public-page-tests.ts scripts/orbit-public-repository-tests.ts scripts/orbit-slice1-tests.ts scripts/orbit-slice1-worker-tests.ts scripts/orbit-slice3-tests.ts",
4343
"test:d1:publication": "tsx --test scripts/orbit-slice4-tests.ts",
4444
"test:d1:platform": "tsx --test scripts/orbit-slice5-tests.ts",
4545
"v6:check": "npm run test:d1 && npm run check && npm run worker:build",

‎scripts/orbit-email-tests.ts‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import assert from 'node:assert/strict';
2+
import { describe, test } from 'node:test';
3+
import { ResendSender, createEmailSender } from '../src/server/notifications/email';
4+
import { announcementEmail, recordRemovedEmail } from '../src/server/notifications/messages';
5+
6+
function sender(respond: (request: Request) => Response | Promise<Response>) {
7+
const seen: Request[] = [];
8+
const instance = new ResendSender(
9+
{ apiKey: 'test-key', from: 'orbit@example.test', replyTo: 'iletisim@example.test' },
10+
async (input, init) => {
11+
const request = new Request(input as never, init as never);
12+
seen.push(request);
13+
return await respond(request);
14+
},
15+
);
16+
return { instance, seen };
17+
}
18+
19+
describe('giden posta', { concurrency: false }, () => {
20+
test('4xx kalıcıdır, 429 ve 5xx geçicidir', async () => {
21+
/* Ayrım tekrar denemeyi belirliyor ve iki yönde de pahalı. Kalıcı bir
22+
* hatayı geçici saymak, geçersiz bir adrese beş kez daha yazmak ve her
23+
* seferinde geri dönen posta üretmek demek — geri dönen posta gönderim
24+
* itibarını bozar ve sonra GERÇEK adreslere de ulaşamayız. Geçici bir
25+
* hatayı kalıcı saymak ise bildirimi tek denemede kaybetmek. */
26+
for (const [status, expected] of [
27+
[400, 'permanent'], [403, 'permanent'], [422, 'permanent'],
28+
[429, 'transient'], [500, 'transient'], [503, 'transient'],
29+
] as const) {
30+
const { instance } = sender(() => new Response('nope', { status }));
31+
const result = await instance.send({ to: 'a@example.test', subject: 's', bodyText: 'b', unsubscribable: false });
32+
assert.equal(result.outcome, expected, `${status} yanlış sınıflandırıldı`);
33+
}
34+
});
35+
36+
test('ağa ulaşılamaması geçicidir, kalıcı değil', async () => {
37+
/* Resend'e ulaşamamak, Resend'in bizi reddettiği anlamına gelmiyor. */
38+
const { instance } = sender(() => { throw new Error('network down'); });
39+
const result = await instance.send({ to: 'a@example.test', subject: 's', bodyText: 'b', unsubscribable: false });
40+
assert.equal(result.outcome, 'transient');
41+
});
42+
43+
test('List-Unsubscribe yalnız kapatılabilir postaya konur', async () => {
44+
/* Kapatılamayan bir postaya bu başlığı koymak, tutulmayacak bir söz
45+
* vermek olur: insan tıklar, hiçbir şey değişmez. */
46+
const { instance, seen } = sender(() => Response.json({ id: 'x' }));
47+
await instance.send({ to: 'a@example.test', subject: 's', bodyText: 'b', unsubscribable: true });
48+
await instance.send({ to: 'a@example.test', subject: 's', bodyText: 'b', unsubscribable: false });
49+
const bodies = await Promise.all(seen.map(async (request) => await request.json() as {
50+
headers?: Record<string, string>;
51+
reply_to?: string;
52+
}));
53+
assert.ok(bodies[0].headers?.['List-Unsubscribe'], 'duyuru postasında çıkış başlığı yok');
54+
assert.equal(bodies[1].headers, undefined, 'kapatılamayan postaya çıkış başlığı konmuş');
55+
assert.equal(bodies[0].reply_to, 'iletisim@example.test', 'yanıtlar insanın okuduğu kutuya gitmiyor');
56+
});
57+
58+
test('anahtar eksikse gönderim kapalıdır ve bu bir arıza değildir', async () => {
59+
/* Yerel geliştirme, test ve staging bu hâlde çalışıyor. Kuyruk
60+
* yazılmaya devam eder, yalnız boşaltılmaz; gönderim açıldığında
61+
* bekleyenler gider. Sessizce "gönderildi" demek yalan olurdu. */
62+
assert.equal(createEmailSender({}), null);
63+
assert.equal(createEmailSender({ RESEND_API_KEY: 'k' }), null, 'gönderen adresi olmadan gönderim açılmış');
64+
assert.equal(createEmailSender({ RESEND_API_KEY: 'k', ORBIT_EMAIL_FROM: 'a@b.test' }), null);
65+
assert.ok(createEmailSender({
66+
RESEND_API_KEY: 'k', ORBIT_EMAIL_FROM: 'a@b.test', ORBIT_EMAIL_REPLY_TO: 'c@b.test',
67+
}));
68+
});
69+
70+
test('posta metinleri kapatma yolunu doğru anlatır', async () => {
71+
const announcement = announcementEmail({
72+
title: 'Kota değişikliği', bodyMarkdown: 'Yayın kotası arttı.', severity: 'warning',
73+
});
74+
assert.match(announcement.subject, /^\[Uyarı\] /u);
75+
assert.match(announcement.bodyText, /dashboard adresinden kapatabilirsin/u);
76+
77+
const moderation = recordRemovedEmail({ agentHandle: 'nyx', reason: 'kural ihlali' });
78+
/* Kapatılamayan postada sebebin yazılı olması lazım: kapatma yolunu
79+
* arayıp bulamayan insan spam işaretler, o da teslim itibarını bozar. */
80+
assert.match(moderation.bodyText, /kapatılamaz/u);
81+
assert.doesNotMatch(moderation.bodyText, /kapatabilirsin/u);
82+
});
83+
});

‎scripts/orbit-production-config-tests.mjs‎

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { isDeepStrictEqual } from 'node:util';
44
const files = {
55
live: new URL('../wrangler.production.live.jsonc', import.meta.url),
66
darkLaunch: new URL('../wrangler.production.dark-launch.jsonc', import.meta.url),
7+
staging: new URL('../wrangler.staging.jsonc', import.meta.url),
78
deployWorkflow: new URL('../.github/workflows/deploy-production.yml', import.meta.url),
89
fullRegressionWorkflow: new URL('../.github/workflows/full-regression.yml', import.meta.url),
910
};
@@ -31,6 +32,22 @@ async function readConfig(url, label) {
3132
}
3233
}
3334

35+
/* Staging dosyası yorum taşıyor ve taşımalı: oradaki cron kısıtının SEBEBİ
36+
* dosyanın kendisinde yazmazsa, biri onu iyi niyetle geri ekler. Katı JSON
37+
* kuralı yalnız iki production dosyası için geçerli — orada yorum yok ki
38+
* gözden kaçan bir alan yorum arkasına saklanamasın. */
39+
async function readJsonc(url, label) {
40+
const text = await readFile(url, 'utf8');
41+
const stripped = text
42+
.replace(/\/\*[\s\S]*?\*\//gu, '')
43+
.replace(/(^|[^:])\/\/.*$/gmu, '$1');
44+
try {
45+
return JSON.parse(stripped);
46+
} catch (error) {
47+
fail(`${label} could not be parsed: ${error.message}`);
48+
}
49+
}
50+
3451
function findForbiddenKeys(value, path = '$') {
3552
const found = [];
3653

@@ -79,6 +96,12 @@ const expectedCommonVars = {
7996
ORBIT_PLATFORM_OWNER_GITHUB_ID: '126420524',
8097
ORBIT_BACKUP_ENABLED: 'true',
8198
ORBIT_MEDIA_ENABLED: 'true',
99+
/* Gönderen adresi doğrulanmış alan adıyla eşleşmek zorunda: Resend'de
100+
* sametbasbug.dev doğrulandı, orbit@ ondan türüyor. Yanıt adresi ise
101+
* gizlilik ve iletişim sayfalarında yazan adresin aynısı — kullanıcıya
102+
* "cevap verme" denmeyen bir kutu göstermek zorundayız. */
103+
ORBIT_EMAIL_FROM: 'Orbit <orbit@sametbasbug.dev>',
104+
ORBIT_EMAIL_REPLY_TO: 'iletisim@sametbasbug.dev',
82105
};
83106

84107
const expectedResources = {
@@ -124,7 +147,10 @@ const expectedResources = {
124147
},
125148
},
126149
triggers: {
127-
crons: ['17 3 * * *', '0 4 * * *'],
150+
/* Üçüncü tetikleyici giden posta kuyruğunu boşaltıyor. Günlük bakıma
151+
* bağlanamaz: bir güvenlik bildiriminin ertesi sabahı beklemesi,
152+
* bildirimi anlamsız kılar. Kuyruk boşken maliyeti tek bir SELECT. */
153+
crons: ['17 3 * * *', '0 4 * * *', '*/5 * * * *'],
128154
},
129155
};
130156

@@ -161,6 +187,8 @@ function validateConfig(config, expected) {
161187
ORBIT_PLATFORM_OWNER_GITHUB_ID: '126420524',
162188
ORBIT_BACKUP_ENABLED: 'true',
163189
ORBIT_MEDIA_ENABLED: 'true',
190+
ORBIT_EMAIL_FROM: 'Orbit <orbit@sametbasbug.dev>',
191+
ORBIT_EMAIL_REPLY_TO: 'iletisim@sametbasbug.dev',
164192
},
165193
`${expected.label} vars drifted`,
166194
);
@@ -181,6 +209,7 @@ function normalizeModeDifferences(config) {
181209

182210
const live = await readConfig(files.live, 'live config');
183211
const darkLaunch = await readConfig(files.darkLaunch, 'dark-launch config');
212+
const staging = await readJsonc(files.staging, 'staging config');
184213
const deployWorkflow = await readFile(files.deployWorkflow, 'utf8');
185214
const fullRegressionWorkflow = await readFile(files.fullRegressionWorkflow, 'utf8');
186215

@@ -204,6 +233,22 @@ assertDeepEqual(
204233
'live and dark-launch configs differ outside the reviewed mode surface',
205234
);
206235

236+
/* Hesap genelinde cron bütçesi. Workers Free planı 5 tetikleyiciye izin
237+
* veriyor ve aynı anda yalnız iki worker yayında: production ile staging.
238+
* Bütçe aşılınca dağıtım sessizce yarım kalıyor — worker yükleniyor, cron
239+
* güncellemesi API'den dönüyor. Bunu bir kez staging'de ödedim; buradan
240+
* sonra derlemede yakalanır. */
241+
const CLOUDFLARE_FREE_CRON_BUDGET = 5;
242+
const deployedCrons = live.triggers.crons.length + staging.triggers.crons.length;
243+
assert(
244+
deployedCrons <= CLOUDFLARE_FREE_CRON_BUDGET,
245+
`production and staging together request ${deployedCrons} cron triggers, over the account limit of ${CLOUDFLARE_FREE_CRON_BUDGET}`,
246+
);
247+
assert(
248+
!staging.triggers.crons.includes('*/5 * * * *'),
249+
'staging claims the outbound email cron: it cannot send without a key and it costs production its third trigger',
250+
);
251+
207252
assert(
208253
deployWorkflow.includes('ORBIT_LIVE_OAUTH_CLIENT_ID: ${{ secrets.ORBIT_LIVE_OAUTH_CLIENT_ID }}'),
209254
'production deploy does not source the live OAuth client ID from the production environment',

‎scripts/orbit-slice1-test-worker.ts‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -722,6 +722,80 @@ async function testRoute(request: Request, env: TestEnv): Promise<Response | nul
722722
return Response.json(await runIdentityCleanup(env, now));
723723
}
724724

725+
/* Giden posta testleri kurulumlarını BURADAN yapıyor, CLI'dan değil.
726+
* wrangler dev veritabanını açık tutarken dışarıdan yazmaya kalkmak
727+
* worker'ı ECONNRESET ile düşürüyor; bir kez ödeyerek öğrendim. */
728+
if (url.pathname === '/__test/seed-email-world') {
729+
/* Test kendi dünyasını TAMAMEN kuruyor: üç hesap, üç kimlik satırı.
730+
* Fikstürdeki hesaplara yaslanmayı denedim ve testi boşa çıkardı —
731+
* onların auth_identities satırı yok, dolayısıyla sorgudaki JOIN
732+
* zaten eliyordu ve tercih süzgecini bozduğumda test yine geçiyordu.
733+
*
734+
* wants → adresi var, istiyor → kuyruğa girmeli
735+
* refuses → adresi var, istemiyor → girmemeli (tercih süzgeci)
736+
* no-email → adresi yok, istiyor → girmemeli (adres süzgeci)
737+
*/
738+
const account = (id: string, handle: string) => env.DB.prepare(`
739+
INSERT OR IGNORE INTO accounts (
740+
id, handle, handle_normalized, display_name, avatar_url,
741+
status, created_at, updated_at, last_login_at
742+
) VALUES (?, ?, ?, ?, NULL, 'active', ?, ?, ?)
743+
`).bind(id, handle, handle, handle, now, now, now);
744+
const identity = (id: string, accountId: string, providerId: string, email: string | null) =>
745+
env.DB.prepare(`
746+
INSERT OR IGNORE INTO auth_identities (
747+
id, account_id, provider, provider_user_id,
748+
provider_login_snapshot, provider_email_snapshot, created_at, last_seen_at
749+
) VALUES (?, ?, 'github', ?, ?, ?, ?, ?)
750+
`).bind(id, accountId, providerId, accountId, email, now, now);
751+
752+
await env.DB.batch([
753+
env.DB.prepare('DELETE FROM email_deliveries'),
754+
account('acc-wants', 'posta-isteyen'),
755+
account('acc-refuses', 'posta-istemeyen'),
756+
account('acc-no-email', 'adressiz'),
757+
identity('ident-wants', 'acc-wants', '900000001', null),
758+
identity('ident-refuses', 'acc-refuses', '900000002', null),
759+
identity('ident-no-email', 'acc-no-email', '900000003', null),
760+
/* Önce herkesi adressiz yap: fikstürde adres taşıyan bir hesap
761+
* kalırsa beklenen sayı tutmaz ve testin ne ölçtüğü belirsizleşir. */
762+
env.DB.prepare('UPDATE auth_identities SET provider_email_snapshot = NULL'),
763+
env.DB.prepare('UPDATE accounts SET announcement_emails_enabled = 1'),
764+
env.DB.prepare("UPDATE auth_identities SET provider_email_snapshot = 'alan@example.test' WHERE account_id = 'acc-wants'"),
765+
env.DB.prepare("UPDATE auth_identities SET provider_email_snapshot = 'istemeyen@example.test' WHERE account_id = 'acc-refuses'"),
766+
env.DB.prepare("UPDATE accounts SET announcement_emails_enabled = 0 WHERE id = 'acc-refuses'"),
767+
]);
768+
return Response.json({ wants: 'acc-wants', refuses: 'acc-refuses', withoutEmail: 'acc-no-email' });
769+
}
770+
771+
/* Bir fikstür hesabına bildirim adresi verir. Yayın reddi testinin
772+
* ihtiyacı bu: reddi veren moderatör ile ajanın sponsoru farklı olmalı
773+
* ve sponsorun adresi bulunmalı, yoksa kuyruk boş kalır ve test hiçbir
774+
* şey ölçmez. */
775+
if (url.pathname === '/__test/set-account-email') {
776+
await env.DB.batch([
777+
env.DB.prepare('DELETE FROM email_deliveries'),
778+
env.DB.prepare(`
779+
INSERT INTO auth_identities (
780+
id, account_id, provider, provider_user_id,
781+
provider_login_snapshot, provider_email_snapshot, created_at, last_seen_at
782+
) VALUES (?, ?, 'github', ?, ?, ?, ?, ?)
783+
ON CONFLICT (account_id, provider) DO UPDATE SET provider_email_snapshot = excluded.provider_email_snapshot
784+
`).bind(
785+
`ident-${String(body.accountId)}`, String(body.accountId), `95${String(body.accountId).slice(-7)}`,
786+
String(body.accountId), String(body.email), now, now,
787+
),
788+
]);
789+
return Response.json({ ok: true });
790+
}
791+
792+
if (url.pathname === '/__test/email-deliveries') {
793+
const rows = await env.DB.prepare(`
794+
SELECT recipient, kind, status, subject FROM email_deliveries ORDER BY created_at ASC
795+
`).all<{ recipient: string; kind: string; status: string; subject: string }>();
796+
return Response.json({ deliveries: rows.results ?? [] });
797+
}
798+
725799
if (url.pathname === '/__test/seed-idempotency') {
726800
await env.DB.prepare(`
727801
INSERT INTO idempotency_keys (

‎scripts/orbit-slice1-worker-tests.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { describe, test } from 'node:test';
33
import worker, {
44
BACKUP_CRON,
55
BACKUP_RECONCILIATION_CRON,
6+
EMAIL_DRAIN_CRON,
67
runScheduledMaintenance,
78
} from '../src/worker';
89
import {
@@ -383,6 +384,7 @@ describe('Orbit V6 deployment-mode contract', () => {
383384
async reconcileStaleBackupRuns() {
384385
calls.push('reconciliation');
385386
},
387+
async drainEmailQueue() { calls.push('email_drain'); },
386388
},
387389
),
388390
/scheduled_maintenance_failed:identity_cleanup/u,
@@ -404,8 +406,33 @@ describe('Orbit V6 deployment-mode contract', () => {
404406
assert.equal(now, scheduledTime);
405407
calls.push('reconciliation');
406408
},
409+
async drainEmailQueue() { calls.push('email_drain'); },
407410
},
408411
);
409412
assert.deepEqual(calls, ['reconciliation']);
410413
});
414+
415+
test('the five-minute cron drains email and touches nothing else', async () => {
416+
/* Posta turu kendi başına dönmeli. Günlük bakımla aynı sepete
417+
* girseydi, yedekleme düştüğünde posta da gönderilmemiş sayılırdı — ve
418+
* o posta bir güvenlik bildirimi olabilir. Ters yönü de önemli: beş
419+
* dakikada bir yedek almaya kalkmak felaket olurdu. */
420+
const calls: string[] = [];
421+
const scheduledTime = 1_785_729_900_000;
422+
await runScheduledMaintenance(
423+
{ cron: EMAIL_DRAIN_CRON, scheduledTime },
424+
productionBindings('live'),
425+
{
426+
async runIdentityCleanup() { calls.push('identity_cleanup'); },
427+
async runScheduledBackups() { calls.push('backup'); },
428+
async cleanupMedia() { calls.push('media_cleanup'); },
429+
async reconcileStaleBackupRuns() { calls.push('reconciliation'); },
430+
async drainEmailQueue(_env, now) {
431+
assert.equal(now, scheduledTime);
432+
calls.push('email_drain');
433+
},
434+
},
435+
);
436+
assert.deepEqual(calls, ['email_drain']);
437+
});
411438
});

0 commit comments

Comments
 (0)