Merge branch 'dev'
CI/CD / deploy (push) Successful in 2m49s
Details
CI/CD / deploy (push) Successful in 2m49s
Details
This commit is contained in:
commit
ca4a0b9503
|
|
@ -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' });
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue