feat(email): system/user senderType + attachments

Composer validator now takes senderType (system|user) and an
attachments[] array, and the service dispatches across two paths:
the system path uses lib/email/index.ts with port-config noreply
identity and logs signed_doc_emailed when an attachment matches a
document's signed PDF; the user path stays on the existing personal-
account flow but is gated by the new email.allowPersonalAccountSends
toggle and the attachment fileIds are persisted on email_messages.
sendEmail in lib/email accepts attachments and resolves them from
MinIO with cross-port enforcement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-04-28 02:48:11 +02:00
parent 9e69c13202
commit 1151768159
4 changed files with 210 additions and 25 deletions

View File

@@ -15,15 +15,25 @@ export const toggleAccountSchema = z.object({
isActive: z.boolean(),
});
export const composeEmailSchema = z.object({
accountId: z.string().uuid(),
threadId: z.string().uuid().optional(),
to: z.array(z.string().email()).min(1),
cc: z.array(z.string().email()).optional(),
subject: z.string().min(1),
bodyHtml: z.string().min(1),
inReplyToMessageId: z.string().optional(),
});
export const composeEmailSchema = z
.object({
senderType: z.enum(['system', 'user']).default('user'),
/** Required when senderType=user; ignored otherwise. */
accountId: z.string().uuid().optional(),
threadId: z.string().uuid().optional(),
to: z.array(z.string().email()).min(1),
cc: z.array(z.string().email()).optional(),
subject: z.string().min(1),
bodyHtml: z.string().min(1),
inReplyToMessageId: z.string().optional(),
attachments: z
.array(z.object({ fileId: z.string().uuid(), filename: z.string().optional() }))
.optional(),
})
.refine((d) => d.senderType !== 'user' || Boolean(d.accountId), {
path: ['accountId'],
message: 'accountId is required when senderType=user',
});
export const listThreadsSchema = z.object({
page: z.coerce.number().int().positive().default(1),