Рассылки, папки по +метке, ловушка только для чужих адресов, служебное API для Jambotron
continuous-integration/drone/pr Build is passing
continuous-integration/drone/pr Build is passing
- V6 mail_lists + mail_list_members: письмо на alerts-prod@ → каждому участнику своя копия в папку рассылки; закрытая рассылка — только от вошедших отправителей (550 снаружи) - bob+prod@ → ящик bob@, папка prod (создаётся сама; системные папки нельзя) - TRAP: чужие адреса → одна копия в INBOX отправителя; наши ящики и рассылки доставляются - /api/service/** (SERVICE_TOKEN из secrets-init): аккаунты (пароль один раз, выключение → вон из рассылок), рассылки - MailDirectory: одно пространство адресов для ящиков и рассылок, без «+» в именах - Настройки → LISTS для администраторов - 86 тестов; живой прогон на Postgres 17: миграции V1–V6, API, SMTP (рассылка/метка/ловушка) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { ApiService } from '../../core/api.service';
|
||||
import { MailList, Mailbox, errorText } from '../../core/models';
|
||||
import { ToastService } from '../../ui/toast.service';
|
||||
|
||||
/**
|
||||
* Settings → LISTS (administrators): alerts-prod@ → a copy to every member, into the list's folder.
|
||||
* Lists kept by Jambotron are edited here too, but Jambotron puts its own member set back on its next sync.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-lists-section',
|
||||
imports: [FormsModule, MatIconModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: { class: 'block xl:col-span-2' },
|
||||
template: `
|
||||
<section class="glass-panel static p-5 md:p-6" aria-labelledby="lists-title">
|
||||
<h2 id="lists-title" class="text-lg font-orbitron font-bold text-cyan-300 flex items-center gap-2 mb-2 border-b border-cyan-500/20 pb-2">
|
||||
<mat-icon class="text-pink-500">forward_to_inbox</mat-icon> LISTS
|
||||
</h2>
|
||||
<p class="text-xs text-slate-400 mb-4">
|
||||
Mail to a list goes to every member — each gets their own copy (own read/unread) in the list's folder.
|
||||
<span class="font-mono">list+tag@</span> puts it into folder «tag» instead. Closed list: only logged-in senders (Kuma, Jambotron).
|
||||
</p>
|
||||
|
||||
<form (ngSubmit)="save()" class="grid grid-cols-1 md:grid-cols-[1fr_1fr_1fr] gap-3 mb-3">
|
||||
<div><label class="synth-label" for="ls-address">Address</label>
|
||||
<div class="flex items-stretch gap-2">
|
||||
<input id="ls-address" name="address" class="synth-input" [ngModel]="local()" (ngModelChange)="local.set($event.toLowerCase().trim())"
|
||||
placeholder="alerts-prod" autocomplete="off" required [readonly]="editing()">
|
||||
<span class="flex items-center text-cyan-300 font-mono">@</span>
|
||||
<span class="flex items-center px-3 rounded-[10px] bg-slate-900/70 border border-cyan-500/30 font-mono text-sm text-slate-200 whitespace-nowrap">{{ domain() }}</span>
|
||||
</div></div>
|
||||
<div><label class="synth-label" for="ls-folder">Folder (empty — INBOX)</label>
|
||||
<input id="ls-folder" name="folder" class="synth-input" [(ngModel)]="folder" maxlength="50" placeholder="prod" autocomplete="off"></div>
|
||||
<div><label class="synth-label" for="ls-about">Description</label>
|
||||
<input id="ls-about" name="about" class="synth-input" [(ngModel)]="description" maxlength="255" autocomplete="off"></div>
|
||||
<fieldset class="md:col-span-3">
|
||||
<legend class="synth-label">Members</legend>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1">
|
||||
@for (m of mailboxes(); track m.id) {
|
||||
<label class="flex items-center gap-1.5 text-sm font-mono" [class.text-slate-500]="!m.active">
|
||||
<input type="checkbox" [checked]="members().has(m.email)" (change)="flip(m.email)"> {{ m.email }}
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="md:col-span-3 flex flex-wrap items-center gap-3">
|
||||
<label class="flex items-center gap-1.5 text-sm"><input type="checkbox" name="open" [(ngModel)]="openToOutside"> open to anyone (not only logged-in senders)</label>
|
||||
<span class="ml-auto"></span>
|
||||
@if (editing()) { <button type="button" class="ghost-btn !py-1" (click)="reset()"><mat-icon>close</mat-icon> CANCEL</button> }
|
||||
<button type="submit" class="cyber-btn px-4 py-2 rounded-lg text-xs font-bold" [disabled]="busy()">
|
||||
<mat-icon>{{ editing() ? 'save' : 'add' }}</mat-icon> {{ editing() ? 'SAVE' : 'CREATE' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
@if (error()) { <p class="text-sm text-pink-400 mb-3" role="alert">{{ error() }}</p> }
|
||||
|
||||
<ul class="divide-y divide-cyan-500/10">
|
||||
@for (l of lists(); track l.id) {
|
||||
<li class="py-2">
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1">
|
||||
<span class="font-mono text-sm text-cyan-300">{{ l.address }}</span>
|
||||
<span class="text-xs font-mono text-slate-400">→ {{ l.folder || 'INBOX' }}</span>
|
||||
@if (l.openToOutside) { <span class="text-[10px] font-mono px-1.5 rounded border border-amber-400/50 text-amber-300">OPEN</span> }
|
||||
<span class="text-sm text-slate-400">{{ l.description }}</span>
|
||||
<span class="ml-auto"></span>
|
||||
<button type="button" class="ghost-btn !py-1" (click)="edit(l)" [attr.aria-label]="'Edit ' + l.address"><mat-icon>edit</mat-icon></button>
|
||||
<button type="button" class="ghost-btn !py-1" (click)="remove(l)" [attr.aria-label]="'Delete ' + l.address"><mat-icon>delete</mat-icon></button>
|
||||
</div>
|
||||
<div class="text-xs font-mono text-slate-500 mt-0.5">{{ l.members.length ? l.members.join(', ') : 'no members — mail to it is dropped' }}</div>
|
||||
</li>
|
||||
} @empty {
|
||||
<li class="py-2 text-sm text-slate-500">No lists yet.</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
`
|
||||
})
|
||||
export class ListsSection {
|
||||
private api = inject(ApiService);
|
||||
private toasts = inject(ToastService);
|
||||
|
||||
readonly lists = signal<MailList[]>([]);
|
||||
readonly mailboxes = signal<Mailbox[]>([]);
|
||||
readonly members = signal<Set<string>>(new Set());
|
||||
readonly local = signal('');
|
||||
readonly domains = signal<string[]>([]);
|
||||
readonly domain = computed(() => this.domains()[0] ?? '');
|
||||
readonly editing = signal(false);
|
||||
readonly error = signal('');
|
||||
readonly busy = signal(false);
|
||||
folder = '';
|
||||
description = '';
|
||||
openToOutside = false;
|
||||
|
||||
constructor() {
|
||||
this.api.config().subscribe({ next: c => this.domains.set(c.domains), error: () => {} });
|
||||
this.api.mailboxes().subscribe({ next: list => this.mailboxes.set(list), error: () => {} });
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.api.mailLists().subscribe({ next: list => this.lists.set(list), error: e => this.error.set(errorText(e)) });
|
||||
}
|
||||
|
||||
flip(email: string): void {
|
||||
const next = new Set(this.members());
|
||||
if (next.has(email)) { next.delete(email); } else { next.add(email); }
|
||||
this.members.set(next);
|
||||
}
|
||||
|
||||
edit(l: MailList): void {
|
||||
this.local.set(l.address.split('@')[0]);
|
||||
this.domains.set([l.address.split('@')[1], ...this.domains().filter(d => d !== l.address.split('@')[1])]);
|
||||
this.folder = l.folder ?? '';
|
||||
this.description = l.description ?? '';
|
||||
this.openToOutside = l.openToOutside;
|
||||
this.members.set(new Set(l.members));
|
||||
this.editing.set(true);
|
||||
this.error.set('');
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.local.set('');
|
||||
this.folder = this.description = '';
|
||||
this.openToOutside = false;
|
||||
this.members.set(new Set());
|
||||
this.editing.set(false);
|
||||
}
|
||||
|
||||
save(): void {
|
||||
const local = this.local().replace(/@.*$/, '');
|
||||
if (!local) { this.error.set('Enter an address'); return; }
|
||||
const address = local + '@' + this.domain();
|
||||
this.error.set('');
|
||||
this.busy.set(true);
|
||||
this.api.saveMailList(address, {
|
||||
folder: this.folder.trim(), description: this.description.trim(), openToOutside: this.openToOutside,
|
||||
members: [...this.members()]
|
||||
}).subscribe({
|
||||
next: saved => { this.busy.set(false); this.toasts.ok('List ' + saved.address + ' saved'); this.reset(); this.load(); },
|
||||
error: e => { this.busy.set(false); this.error.set(errorText(e)); }
|
||||
});
|
||||
}
|
||||
|
||||
remove(l: MailList): void {
|
||||
if (!confirm('Delete list ' + l.address + '? Mail to it will be refused.')) return;
|
||||
this.api.deleteMailList(l.address).subscribe({
|
||||
next: () => { this.toasts.ok('List ' + l.address + ' deleted'); this.load(); },
|
||||
error: e => this.toasts.error(errorText(e))
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user