Merge branch 'dev'
CI/CD / deploy (push) Successful in 2m49s Details

This commit is contained in:
Jeff Emmett 2026-04-17 11:35:56 -04:00
commit ca4a0b9503
1 changed files with 75 additions and 26 deletions

View File

@ -24,6 +24,57 @@ async function mailcowFetch(path: string, options: RequestInit = {}): Promise<Re
});
}
/**
* Parse a Mailcow write-endpoint response and throw on silent failures.
*
* Mailcow has two failure modes that look like success if you only check HTTP status:
* 1. HTTP 200 + `[{"type":"danger","msg":"..."}]` validated error
* 2. HTTP 200 + `{"type":"success","msg":"Task completed"}` after silently
* coercing a malformed payload (wrong shape, missing required field,
* failed ACL/authsource gate). No change persists in the DB.
*
* The defensive path: require HTTP 2xx, read the body, reject anything with
* `type: "danger"` / `"error"`, and require the per-item success marker
* (e.g. "mailbox_modified", "alias_added") when one is expected. The generic
* `msg: "Task completed"` is treated as suspicious for edit/delete ops
* because it's what mailcow emits when the batch ran but produced no
* per-item updates i.e. every item was silently skipped.
*/
async function parseMailcowResponse(
res: Response,
op: string,
opts: { expectMsgContains?: string } = {},
): Promise<any> {
if (!res.ok) {
const text = await res.text().catch(() => '<no body>');
throw new Error(`Mailcow ${op} failed (HTTP ${res.status}): ${text}`);
}
const body = await res.json().catch(() => null);
if (body === null) return null;
const entries = Array.isArray(body) ? body : [body];
const errors = entries.filter((e) => e?.type === 'danger' || e?.type === 'error');
if (errors.length > 0) {
throw new Error(`Mailcow ${op} returned error: ${JSON.stringify(errors.map((e) => e.msg))}`);
}
if (opts.expectMsgContains) {
const hasDetail = entries.some((e) => {
const msg = e?.msg;
if (typeof msg === 'string') return msg.includes(opts.expectMsgContains!);
if (Array.isArray(msg)) return msg.some((m) => typeof m === 'string' && m.includes(opts.expectMsgContains!));
return false;
});
if (!hasDetail) {
throw new Error(
`Mailcow ${op} returned generic success without "${opts.expectMsgContains}" — likely silent no-op (check payload shape). Body: ${JSON.stringify(body)}`,
);
}
}
return body;
}
/**
* Create a forwarding alias.
* Returns the Mailcow alias ID on success, or throws on failure.
@ -39,12 +90,8 @@ export async function createAlias(username: string, targetEmail: string): Promis
}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Mailcow createAlias failed (${res.status}): ${text}`);
}
await parseMailcowResponse(res, 'createAlias', { expectMsgContains: 'alias_added' });
// Mailcow returns an array with status objects; look up the alias ID
const id = await findAliasId(address);
if (!id) throw new Error('Alias created but could not retrieve ID');
return id;
@ -59,10 +106,7 @@ export async function deleteAlias(aliasId: string): Promise<void> {
body: JSON.stringify([aliasId]),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Mailcow deleteAlias failed (${res.status}): ${text}`);
}
await parseMailcowResponse(res, 'deleteAlias', { expectMsgContains: 'alias_removed' });
}
/**
@ -80,10 +124,7 @@ export async function updateAlias(aliasId: string, newTargetEmail: string): Prom
}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Mailcow updateAlias failed (${res.status}): ${text}`);
}
await parseMailcowResponse(res, 'updateAlias', { expectMsgContains: 'alias_modified' });
}
/**
@ -133,16 +174,27 @@ export async function createMailbox(localPart: string, password: string): Promis
}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Mailcow createMailbox failed (${res.status}): ${text}`);
}
await parseMailcowResponse(res, 'createMailbox', { expectMsgContains: 'mailbox_added' });
}
// Mailcow returns [{type, log, msg}] — check for errors
const body = await res.json();
if (Array.isArray(body) && body[0]?.type === 'danger') {
throw new Error(`Mailcow createMailbox error: ${JSON.stringify(body[0].msg)}`);
}
/**
* Update attributes (password, quota, access flags, etc.) on an existing mailbox.
*
* Payload MUST be `{items, attr}` at the top level if a caller passes an
* array-of-objects form, mailcow silently coerces attr to null and returns
* generic "Task completed" without applying any change. The `authsource`
* attribute is required for any real edit to happen (mailcow PHP gates on it).
*/
export async function updateMailbox(address: string, attrs: Record<string, unknown>): Promise<void> {
const res = await mailcowFetch('/api/v1/edit/mailbox', {
method: 'POST',
body: JSON.stringify({
items: [address],
attr: { authsource: 'mailcow', ...attrs },
}),
});
await parseMailcowResponse(res, 'updateMailbox', { expectMsgContains: 'mailbox_modified' });
}
/**
@ -154,10 +206,7 @@ export async function deleteMailbox(address: string): Promise<void> {
body: JSON.stringify([address]),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Mailcow deleteMailbox failed (${res.status}): ${text}`);
}
await parseMailcowResponse(res, 'deleteMailbox', { expectMsgContains: 'mailbox_removed' });
}
/**