diff --git a/src/encryptid/mailcow.ts b/src/encryptid/mailcow.ts index 9f07a42d..919f912f 100644 --- a/src/encryptid/mailcow.ts +++ b/src/encryptid/mailcow.ts @@ -24,6 +24,57 @@ async function mailcowFetch(path: string, options: RequestInit = {}): Promise { + if (!res.ok) { + const text = await res.text().catch(() => ''); + 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 { 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): Promise { + 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 { 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' }); } /**