mailu: full nox module — client, tools and events (ADR 0044/0046)
Email server. Users/aliases/domains over the admin REST API, mail read via doveadm in the imap container; ten tools. Emits user/alias created/deleted by polling and diffing the admin API, so a change in the web admin is announced as readily as one via a tool. Consumes nothing — deliberately, since mutating mail accounts off another module's event could silently lose mail. Typechecks against the sdk; manifest parses. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
This commit is contained in:
@@ -0,0 +1,220 @@
|
|||||||
|
// The Mailu API client — mailu's own code, living in the module (novox/hq ADR 0044). Moved out of
|
||||||
|
// the shared hal sdk, where a change to Mailu's surface rebuilt everything; here it rebuilds only
|
||||||
|
// mailu. Both this module's tools and its events entrypoint import it, and nothing outside mailu does.
|
||||||
|
//
|
||||||
|
// hal drove Mailu through its flask CLI over `docker compose exec` into the admin container. That
|
||||||
|
// coupling was to the container, not to Mailu: it needed a shell on the box. The module's real
|
||||||
|
// coupling is the admin REST API, so that is what this client speaks — a token and a URL, no shell.
|
||||||
|
// The one exception is reading a mailbox: the admin API exposes no message reads, so that alone
|
||||||
|
// falls back to `doveadm` inside the imap container, the operation the HTTP surface cannot serve.
|
||||||
|
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
|
const run = promisify(execFile);
|
||||||
|
|
||||||
|
export interface MailuUser {
|
||||||
|
email: string;
|
||||||
|
displayed_name?: string;
|
||||||
|
global_admin?: boolean;
|
||||||
|
enabled?: boolean;
|
||||||
|
forward_enabled?: boolean;
|
||||||
|
forward_destination?: string[];
|
||||||
|
quota_bytes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MailuAlias {
|
||||||
|
email: string;
|
||||||
|
destination: string[];
|
||||||
|
wildcard?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MailuDomain {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One parsed message from a doveadm fetch — the subset the read/search tools surface. */
|
||||||
|
export interface MailMessage {
|
||||||
|
date?: string;
|
||||||
|
from?: string;
|
||||||
|
subject?: string;
|
||||||
|
preview?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The fields we ask doveadm for, once — kept together so read and search stay identical in shape.
|
||||||
|
const FETCH_FIELDS = "date.received hdr.subject hdr.from body.snippet";
|
||||||
|
|
||||||
|
export class MailuClient {
|
||||||
|
readonly baseUrl: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
url: string,
|
||||||
|
private readonly apiKey: string,
|
||||||
|
/** The container name doveadm runs in — reads bypass the API, so they need the box, not a token. */
|
||||||
|
private readonly imapContainer: string,
|
||||||
|
) {
|
||||||
|
this.baseUrl = url.replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build from the module's resolved environment. MESH_MAILU_URL points at the admin API (e.g. the
|
||||||
|
* admin container's /api/v1), MESH_MAILU_API_KEY authenticates against it. Both are required — a
|
||||||
|
* client with neither would only fail later, one call at a time, so it fails here instead.
|
||||||
|
*/
|
||||||
|
static fromEnv(env: NodeJS.ProcessEnv = process.env): MailuClient {
|
||||||
|
const url = env.MESH_MAILU_URL;
|
||||||
|
const apiKey = env.MESH_MAILU_API_KEY;
|
||||||
|
if (!url || !apiKey) {
|
||||||
|
throw new Error("Mailu is not configured — set MESH_MAILU_URL and MESH_MAILU_API_KEY");
|
||||||
|
}
|
||||||
|
const imapContainer = env.MESH_MAILU_IMAP_CONTAINER ?? "mailu-imap";
|
||||||
|
return new MailuClient(url, apiKey, imapContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- The admin REST API: users, aliases, domains. ---------------------------------------------
|
||||||
|
|
||||||
|
private async api<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
|
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
// Mailu's admin API takes the token directly in Authorization, no scheme prefix.
|
||||||
|
Authorization: this.apiKey,
|
||||||
|
Accept: "application/json",
|
||||||
|
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
|
||||||
|
},
|
||||||
|
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Mailu API ${method} ${path}: ${res.status} ${await res.text()}`);
|
||||||
|
// DELETE and some writes answer with an empty body or a bare string; guard the JSON parse.
|
||||||
|
const text = await res.text();
|
||||||
|
return (text ? JSON.parse(text) : undefined) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listUsers(): Promise<MailuUser[]> {
|
||||||
|
const users = await this.api<any[]>("GET", "/user");
|
||||||
|
return (users ?? []).map((u) => ({
|
||||||
|
email: u.email,
|
||||||
|
displayed_name: u.displayed_name,
|
||||||
|
global_admin: u.global_admin,
|
||||||
|
enabled: u.enabled,
|
||||||
|
forward_enabled: u.forward_enabled,
|
||||||
|
forward_destination: u.forward_destination,
|
||||||
|
quota_bytes: u.quota_bytes,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a mailbox. Mailu wants the full address and the plaintext password it will hash. */
|
||||||
|
async createUser(email: string, password: string): Promise<void> {
|
||||||
|
await this.api("POST", "/user", { email, raw_password: password });
|
||||||
|
}
|
||||||
|
|
||||||
|
async changePassword(email: string, password: string): Promise<void> {
|
||||||
|
await this.api("PATCH", `/user/${encodeURIComponent(email)}`, { raw_password: password });
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteUser(email: string): Promise<void> {
|
||||||
|
await this.api("DELETE", `/user/${encodeURIComponent(email)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAliases(): Promise<MailuAlias[]> {
|
||||||
|
const aliases = await this.api<any[]>("GET", "/alias");
|
||||||
|
return (aliases ?? []).map((a) => ({
|
||||||
|
email: a.email,
|
||||||
|
// The API returns destination as a comma-joined string on some versions, a list on others.
|
||||||
|
destination: Array.isArray(a.destination)
|
||||||
|
? a.destination
|
||||||
|
: String(a.destination ?? "").split(",").map((d: string) => d.trim()).filter(Boolean),
|
||||||
|
wildcard: a.wildcard,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAlias(email: string, destination: string[], wildcard = false): Promise<void> {
|
||||||
|
await this.api("POST", "/alias", { email, destination, wildcard });
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAlias(email: string): Promise<void> {
|
||||||
|
await this.api("DELETE", `/alias/${encodeURIComponent(email)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listDomains(): Promise<MailuDomain[]> {
|
||||||
|
const domains = await this.api<any[]>("GET", "/domain");
|
||||||
|
return (domains ?? []).map((d) => ({ name: d.name }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Reading mail: doveadm, because the admin API has no message reads. -----------------------
|
||||||
|
|
||||||
|
/** Recent messages in a mailbox, newest last, capped to `limit`. */
|
||||||
|
async readMail(user: string, mailbox = "INBOX", limit = 10): Promise<MailMessage[]> {
|
||||||
|
const messages = await this.doveadmFetch(user, ["mailbox", mailbox]);
|
||||||
|
return messages.slice(-limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search a mailbox by subject, sender and/or date. doveadm fetch takes a search query directly,
|
||||||
|
* so we build one from whichever criteria were given — `all` when none were, to avoid an empty
|
||||||
|
* query that would match nothing.
|
||||||
|
*/
|
||||||
|
async searchMail(
|
||||||
|
user: string,
|
||||||
|
criteria: { subject?: string; from?: string; since?: string },
|
||||||
|
limit = 10,
|
||||||
|
): Promise<MailMessage[]> {
|
||||||
|
const query: string[] = [];
|
||||||
|
if (criteria.subject) query.push("subject", criteria.subject);
|
||||||
|
if (criteria.from) query.push("from", criteria.from);
|
||||||
|
if (criteria.since) query.push("since", criteria.since);
|
||||||
|
if (query.length === 0) query.push("all");
|
||||||
|
const messages = await this.doveadmFetch(user, query);
|
||||||
|
return messages.slice(-limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doveadmFetch(user: string, query: string[]): Promise<MailMessage[]> {
|
||||||
|
const output = await run(
|
||||||
|
"docker",
|
||||||
|
["exec", "-i", this.imapContainer, "doveadm", "fetch", "-u", user, FETCH_FIELDS, ...query],
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.then((r) => r.stdout)
|
||||||
|
// An empty mailbox is not an error; doveadm says so on stderr and exits non-zero.
|
||||||
|
.catch((e: { stderr?: string; message?: string }) => {
|
||||||
|
const text = `${e.stderr ?? ""}${e.message ?? ""}`;
|
||||||
|
if (text.includes("no matching mails")) return "";
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
return parseDoveadmFetch(output).map(toMailMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// doveadm fetch prints one record per message, records separated by a blank line (a form feed in
|
||||||
|
// some builds), each field on its own `name: value` line. A folded value continues on later lines.
|
||||||
|
function parseDoveadmFetch(output: string): Array<Record<string, string>> {
|
||||||
|
const messages: Array<Record<string, string>> = [];
|
||||||
|
let current: Record<string, string> = {};
|
||||||
|
for (const line of output.split("\n")) {
|
||||||
|
if (line === "" || line === "\f") {
|
||||||
|
if (Object.keys(current).length) {
|
||||||
|
messages.push(current);
|
||||||
|
current = {};
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const colonIdx = line.indexOf(": ");
|
||||||
|
if (colonIdx > 0) {
|
||||||
|
const key = line.slice(0, colonIdx);
|
||||||
|
const value = line.slice(colonIdx + 2);
|
||||||
|
current[key] = current[key] ? `${current[key]}\n${value}` : value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(current).length) messages.push(current);
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toMailMessage(m: Record<string, string>): MailMessage {
|
||||||
|
const preview = m["body.snippet"];
|
||||||
|
return {
|
||||||
|
date: m["date.received"]?.trim(),
|
||||||
|
from: m["hdr.from"]?.trim(),
|
||||||
|
subject: m["hdr.subject"]?.trim(),
|
||||||
|
preview: preview ? preview.trim().slice(0, 200) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// mailu's events. The tool runtime imports this once the broker is bound. It watches the mail
|
||||||
|
// server's accounts and announces what changed, so the rest of the mesh can react to a mailbox
|
||||||
|
// appearing or an alias being pointed somewhere new.
|
||||||
|
//
|
||||||
|
// Emits (novox/hq ADR 0046/0047):
|
||||||
|
// module.mailu.user.created / .deleted — a mailbox appeared or was removed
|
||||||
|
// module.mailu.alias.created / .deleted — an alias was added or removed
|
||||||
|
//
|
||||||
|
// Consumes: nothing. Mail accounts are not something the mesh should mutate in the background off
|
||||||
|
// another module's event — a wrong reaction here silently loses mail. mailu observes and announces;
|
||||||
|
// it does not act on what others do. If a real consumer is ever wanted, it is a deliberate addition.
|
||||||
|
//
|
||||||
|
// Detection is by polling the admin API and diffing, exactly as plex diffs its sessions: the change
|
||||||
|
// may have come from the web admin as easily as from a tool, and a diff catches both. The first
|
||||||
|
// look primes silently, or a restart would re-announce every existing account as freshly created.
|
||||||
|
|
||||||
|
import { emit } from "@novox/mesh-sdk/events";
|
||||||
|
import { MailuClient } from "./client.js";
|
||||||
|
|
||||||
|
const mailu = MailuClient.fromEnv();
|
||||||
|
|
||||||
|
// A generic diff over a keyed set: emit `created` for keys that appeared, `deleted` for keys that
|
||||||
|
// went away, and stay silent until primed. Users and aliases are the same shape of watch.
|
||||||
|
function watcher(created: string, deleted: string): (keys: string[]) => Promise<void> {
|
||||||
|
const known = new Set<string>();
|
||||||
|
let primed = false;
|
||||||
|
return async (keys: string[]) => {
|
||||||
|
const now = new Set(keys);
|
||||||
|
if (primed) {
|
||||||
|
for (const key of now) if (!known.has(key)) await emit(created, { email: key });
|
||||||
|
for (const key of known) if (!now.has(key)) await emit(deleted, { email: key });
|
||||||
|
}
|
||||||
|
known.clear();
|
||||||
|
for (const key of now) known.add(key);
|
||||||
|
primed = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const watchUsers = watcher("module.mailu.user.created", "module.mailu.user.deleted");
|
||||||
|
const watchAliases = watcher("module.mailu.alias.created", "module.mailu.alias.deleted");
|
||||||
|
|
||||||
|
async function pollUsers(): Promise<void> {
|
||||||
|
await watchUsers((await mailu.listUsers()).map((u) => u.email));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollAliases(): Promise<void> {
|
||||||
|
await watchAliases((await mailu.listAliases()).map((a) => a.email));
|
||||||
|
}
|
||||||
|
|
||||||
|
const tick = (fn: () => Promise<void>, everyMs: number): void => {
|
||||||
|
const runOnce = (): void => void fn().catch((err) => console.error(`[mailu] ${err}`));
|
||||||
|
setInterval(runOnce, everyMs);
|
||||||
|
runOnce();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Accounts and aliases change on human time, not machine time — a minute's latency is fine, and
|
||||||
|
// polling the admin API harder buys immediacy nobody asked for.
|
||||||
|
tick(pollUsers, 60_000);
|
||||||
|
tick(pollAliases, 60_000);
|
||||||
|
|
||||||
|
console.log("[mailu] watching users and aliases");
|
||||||
@@ -4,6 +4,12 @@
|
|||||||
"capabilities": [
|
"capabilities": [
|
||||||
"container-runtime"
|
"container-runtime"
|
||||||
],
|
],
|
||||||
|
"emits": [
|
||||||
|
"module.mailu.user.created",
|
||||||
|
"module.mailu.user.deleted",
|
||||||
|
"module.mailu.alias.created",
|
||||||
|
"module.mailu.alias.deleted"
|
||||||
|
],
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
"port": 25,
|
"port": 25,
|
||||||
@@ -43,7 +49,8 @@
|
|||||||
"own-secrets": {
|
"own-secrets": {
|
||||||
"secret-key": "/var/lib/mailu/secret-key.secret",
|
"secret-key": "/var/lib/mailu/secret-key.secret",
|
||||||
"database": "/var/lib/mailu/database.secret",
|
"database": "/var/lib/mailu/database.secret",
|
||||||
"admin": "/var/lib/mailu/admin.secret"
|
"admin": "/var/lib/mailu/admin.secret",
|
||||||
|
"broker": "/var/lib/mailu/broker"
|
||||||
},
|
},
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "@novox/module-mailu",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "mailu — mail server. Its API client, tools and events live here (novox/hq ADR 0044).",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@novox/mesh-sdk": "^0.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"typescript": "^5.6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
// mailu's tools — moved here from the shared sdk (novox/hq ADR 0044), importing mailu's own client.
|
||||||
|
// They return structured data; the mesh serves them through the sdk's tool harness. Deletions are
|
||||||
|
// guarded by an explicit `confirm`, since removing a mailbox destroys its mail and cannot be undone.
|
||||||
|
|
||||||
|
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||||
|
import { MailuClient } from "../client.js";
|
||||||
|
|
||||||
|
export function getMailuTools(mailu: MailuClient): ToolDefinition[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: "mailu_list_users",
|
||||||
|
description: "List all email accounts on the mail server.",
|
||||||
|
input: {},
|
||||||
|
run: async () => {
|
||||||
|
const users = await mailu.listUsers();
|
||||||
|
return { count: users.length, users };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mailu_create_user",
|
||||||
|
description: "Create a new email account.",
|
||||||
|
input: {
|
||||||
|
email: { type: "string", description: "full address, e.g. user@example.com" },
|
||||||
|
password: { type: "string", description: "the account's initial password" },
|
||||||
|
},
|
||||||
|
run: async (args) => {
|
||||||
|
const email = String(args.email);
|
||||||
|
await mailu.createUser(email, String(args.password));
|
||||||
|
return { created: email };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mailu_change_password",
|
||||||
|
description: "Change the password of an email account.",
|
||||||
|
input: {
|
||||||
|
email: { type: "string", description: "full address of the account" },
|
||||||
|
password: { type: "string", description: "the new password" },
|
||||||
|
},
|
||||||
|
run: async (args) => {
|
||||||
|
const email = String(args.email);
|
||||||
|
await mailu.changePassword(email, String(args.password));
|
||||||
|
return { changed: email };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mailu_delete_user",
|
||||||
|
description: "Delete an email account. DESTRUCTIVE — removes the mailbox and all its mail.",
|
||||||
|
input: {
|
||||||
|
email: { type: "string", description: "full address of the account to delete" },
|
||||||
|
confirm: { type: "boolean", description: "must be true — deletion is irreversible" },
|
||||||
|
},
|
||||||
|
run: async (args) => {
|
||||||
|
const email = String(args.email);
|
||||||
|
if (args.confirm !== true) return { aborted: "confirm must be true to delete a user", email };
|
||||||
|
await mailu.deleteUser(email);
|
||||||
|
return { deleted: email };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mailu_list_aliases",
|
||||||
|
description: "List all email aliases and where they forward.",
|
||||||
|
input: {},
|
||||||
|
run: async () => {
|
||||||
|
const aliases = await mailu.listAliases();
|
||||||
|
return { count: aliases.length, aliases };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mailu_create_alias",
|
||||||
|
description: "Create an email alias forwarding to one or more destinations.",
|
||||||
|
input: {
|
||||||
|
localpart: { type: "string", description: "the part before @, e.g. 'sales'" },
|
||||||
|
domain: { type: "string", description: "the domain, e.g. example.com" },
|
||||||
|
destination: { type: "string", description: "destination address(es), comma-separated" },
|
||||||
|
wildcard: { type: "boolean", description: "match any localpart under the domain (optional)" },
|
||||||
|
},
|
||||||
|
run: async (args) => {
|
||||||
|
const email = `${String(args.localpart)}@${String(args.domain)}`;
|
||||||
|
const destination = String(args.destination).split(",").map((d) => d.trim()).filter(Boolean);
|
||||||
|
await mailu.createAlias(email, destination, args.wildcard === true);
|
||||||
|
return { created: email, destination };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mailu_delete_alias",
|
||||||
|
description: "Delete an email alias.",
|
||||||
|
input: {
|
||||||
|
email: { type: "string", description: "the alias address to delete" },
|
||||||
|
confirm: { type: "boolean", description: "must be true to confirm deletion" },
|
||||||
|
},
|
||||||
|
run: async (args) => {
|
||||||
|
const email = String(args.email);
|
||||||
|
if (args.confirm !== true) return { aborted: "confirm must be true to delete an alias", email };
|
||||||
|
await mailu.deleteAlias(email);
|
||||||
|
return { deleted: email };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mailu_list_domains",
|
||||||
|
description: "List the mail domains the server handles.",
|
||||||
|
input: {},
|
||||||
|
run: async () => {
|
||||||
|
const domains = await mailu.listDomains();
|
||||||
|
return { count: domains.length, domains };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mailu_read_mail",
|
||||||
|
description: "Read recent messages in a user's mailbox — date, from, subject and a preview.",
|
||||||
|
input: {
|
||||||
|
user: { type: "string", description: "the mailbox owner's address" },
|
||||||
|
mailbox: { type: "string", description: "which mailbox (default INBOX)" },
|
||||||
|
limit: { type: "number", description: "how many recent messages (default 10)" },
|
||||||
|
},
|
||||||
|
run: async (args) => {
|
||||||
|
const user = String(args.user);
|
||||||
|
const mailbox = args.mailbox ? String(args.mailbox) : "INBOX";
|
||||||
|
const messages = await mailu.readMail(user, mailbox, args.limit ? Number(args.limit) : 10);
|
||||||
|
return { user, mailbox, count: messages.length, messages };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mailu_search_mail",
|
||||||
|
description: "Search a user's mailbox by subject, sender, and/or date.",
|
||||||
|
input: {
|
||||||
|
user: { type: "string", description: "the mailbox owner's address" },
|
||||||
|
subject: { type: "string", description: "substring to match in the subject (optional)" },
|
||||||
|
from: { type: "string", description: "sender address or name to match (optional)" },
|
||||||
|
since: { type: "string", description: "only messages since a date, e.g. 01-Jan-2026 (optional)" },
|
||||||
|
limit: { type: "number", description: "maximum results (default 10)" },
|
||||||
|
},
|
||||||
|
run: async (args) => {
|
||||||
|
const user = String(args.user);
|
||||||
|
const criteria = {
|
||||||
|
subject: args.subject ? String(args.subject) : undefined,
|
||||||
|
from: args.from ? String(args.from) : undefined,
|
||||||
|
since: args.since ? String(args.since) : undefined,
|
||||||
|
};
|
||||||
|
const messages = await mailu.searchMail(user, criteria, args.limit ? Number(args.limit) : 10);
|
||||||
|
return { user, count: messages.length, messages };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// The tools exist only when the admin API is configured; without it, mailu contributes none rather
|
||||||
|
// than failing the whole runtime.
|
||||||
|
registerModuleTools("mailu", (env) => {
|
||||||
|
try {
|
||||||
|
return getMailuTools(MailuClient.fromEnv(env));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["client.ts", "index.ts", "tools/index.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user