import { z } from 'zod'; import { getActionRegistryV2 } from '../../registries/actionRegistry'; import { getWorkflowEmailProvider } from '../../registries/workflowEmailRegistry'; import { EmailProviderError } from '@alga-psa/types'; import { uuidSchema, isoDateTimeSchema, actionProvidedKey, withTenantTransaction, requirePermission, writeRunAudit, throwActionError, MAX_ATTACHMENT_BYTES, isAllowedAttachmentMimeType } from './shared'; export function registerEmailActions(): void { const registry = getActionRegistryV2(); // --------------------------------------------------------------------------- // A13 — email.send // --------------------------------------------------------------------------- registry.register({ id: 'email.send', version: 1, inputSchema: z.object({ to: z.array(z.object({ email: z.string().email(), name: z.string().optional() })).min(1).describe('Recipients'), cc: z.array(z.object({ email: z.string().email(), name: z.string().optional() })).optional(), bcc: z.array(z.object({ email: z.string().email(), name: z.string().optional() })).optional(), from: z.object({ email: z.string().email(), name: z.string().optional() }).optional().describe('Optional from override'), subject: z.string().min(1).describe('Subject template (supports {{var}})'), html: z.string().optional().describe('HTML template (supports {{var}})'), text: z.string().optional().describe('Text template (supports {{var}})'), template_data: z.record(z.unknown()).optional().describe('Template data for {{var}} replacement'), attachment_file_ids: z.array(uuidSchema).optional().describe('Attachment file ids (external_files.file_id)'), provider_id: z.string().optional().describe('Optional provider override (providerId from tenant email settings)'), idempotency_key: z.string().optional().describe('Optional external idempotency key') }), outputSchema: z.object({ success: z.boolean(), message_id: z.string().nullable(), provider_id: z.string().nullable(), provider_type: z.string().nullable(), status: z.enum(['sent']).describe('Delivery status'), sent_at: isoDateTimeSchema.nullable() }), sideEffectful: true, retryHint: { maxAttempts: 3, backoffMs: 1000, retryOn: ['TransientError'] }, idempotency: { mode: 'actionProvided', key: actionProvidedKey }, ui: { label: 'Send Email', category: 'Business Operations', description: 'Send an outbound email via tenant email settings' }, handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => { // Use the existing email permission taxonomy (email:process). await requirePermission(ctx, tx, { resource: 'email', action: 'process' }); const { TenantEmailService, StaticTemplateProcessor, EmailProviderManager } = getWorkflowEmailProvider(); const settings = await TenantEmailService.getTenantEmailSettings(tx.tenantId, tx.trx); if (!settings) { throwActionError(ctx, { category: 'ActionError', code: 'VALIDATION_ERROR', message: 'Tenant email settings not configured' }); } const providerConfigs = Array.isArray(settings.providerConfigs) ? [...settings.providerConfigs] : []; if (input.provider_id) { const idx = providerConfigs.findIndex((c) => c.providerId === input.provider_id); if (idx === -1) { throwActionError(ctx, { category: 'ValidationError', code: 'VALIDATION_ERROR', message: 'Unknown provider_id' }); } const [picked] = providerConfigs.splice(idx, 1); providerConfigs.unshift(picked!); } const manager = new EmailProviderManager(); await manager.initialize({ ...settings, providerConfigs } as any); const providers = await manager.getAvailableProviders(tx.tenantId); const provider = providers[0] ?? null; if (!provider) { throwActionError(ctx, { category: 'ActionError', code: 'VALIDATION_ERROR', message: 'No enabled email provider configured' }); } // Build content via static templating. const templateProcessor = new StaticTemplateProcessor(input.subject, input.html ?? '', input.text); const content = await templateProcessor.process({ templateData: (input.template_data ?? {}) as any }); // Resolve from address. const resolveDefaultFrom = (): { email: string; name?: string } => { const fallbackDomain = settings.defaultFromDomain || settings.customDomains?.[0]; const email = settings.ticketingFromEmail || (fallbackDomain ? `no-reply@${fallbackDomain}` : null); if (!email) { throwActionError(ctx, { category: 'ValidationError', code: 'VALIDATION_ERROR', message: 'No default From address configured for tenant' }); } return { email }; }; const from = input.from ?? resolveDefaultFrom(); // From domain constraints: allow tenant custom domains or the defaultFromDomain. const fromDomain = String(from.email).split('@')[1]?.toLowerCase() ?? ''; const allowedDomains = new Set([ ...(settings.customDomains ?? []).map((d: string) => String(d).toLowerCase()), ...(settings.defaultFromDomain ? [String(settings.defaultFromDomain).toLowerCase()] : []) ]); if (fromDomain && allowedDomains.size > 0 && !allowedDomains.has(fromDomain)) { throwActionError(ctx, { category: 'ValidationError', code: 'VALIDATION_ERROR', message: 'From address domain is not allowed for this tenant' }); } // Attachments via storage file refs. const attachmentFileIds = Array.isArray(input.attachment_file_ids) ? input.attachment_file_ids : []; const attachments: Array<{ filename: string; content: Buffer; contentType?: string }> = []; if (attachmentFileIds.length) { const { StorageProviderFactory } = await import('@alga-psa/storage'); if (!provider.capabilities.supportsAttachments) { throwActionError(ctx, { category: 'ValidationError', code: 'VALIDATION_ERROR', message: 'Email provider does not support attachments' }); } const maxPerAttachment = provider.capabilities.maxAttachmentSize ?? MAX_ATTACHMENT_BYTES; const storage = await StorageProviderFactory.createProvider(); for (const fileId of attachmentFileIds) { const file = await tx.trx('external_files').where({ tenant: tx.tenantId, file_id: fileId, is_deleted: false }).first(); if (!file) { throwActionError(ctx, { category: 'ActionError', code: 'NOT_FOUND', message: 'Attachment file not found', details: { file_id: fileId } }); } const size = Number(file.file_size ?? 0); if (size > maxPerAttachment) { throwActionError(ctx, { category: 'ValidationError', code: 'VALIDATION_ERROR', message: 'Attachment too large' }); } const mimeType = (file.mime_type as string | null) ?? null; if (!isAllowedAttachmentMimeType(mimeType)) { throwActionError(ctx, { category: 'ValidationError', code: 'VALIDATION_ERROR', message: 'Attachment mime_type not allowed' }); } const content = await storage.download(String(file.storage_path)); attachments.push({ filename: String(file.original_name ?? file.file_name ?? 'attachment'), content, contentType: mimeType ?? undefined }); } } const recipientsCount = (input.to?.length ?? 0) + (input.cc?.length ?? 0) + (input.bcc?.length ?? 0); const maxRecipients = provider.capabilities.maxRecipientsPerMessage ?? 100; if (recipientsCount > maxRecipients) { throwActionError(ctx, { category: 'ValidationError', code: 'VALIDATION_ERROR', message: 'Too many recipients for email provider' }); } try { const result = await manager.sendEmail( { from, to: input.to, cc: input.cc, bcc: input.bcc, subject: content.subject, html: content.html, text: content.text, attachments: attachments.length ? attachments : undefined } as any, tx.tenantId ); if (!result.success) { throwActionError(ctx, { category: 'TransientError', code: 'TRANSIENT_FAILURE', message: result.error ?? 'Email send failed' }); } await writeRunAudit(ctx, tx, { operation: 'workflow_action:email.send', changedData: { to_count: input.to.length, cc_count: input.cc?.length ?? 0, bcc_count: input.bcc?.length ?? 0 }, details: { action_id: 'email.send', action_version: 1, provider_id: result.providerId, provider_type: result.providerType, message_id: result.messageId ?? null } }); return { success: true, message_id: result.messageId ?? null, provider_id: result.providerId ?? null, provider_type: result.providerType ?? null, status: 'sent' as const, sent_at: result.sentAt ? new Date(result.sentAt).toISOString() : null }; } catch (error) { if (error instanceof EmailProviderError) { if ((error.errorCode ?? '').toUpperCase().includes('RATE')) { throwActionError(ctx, { category: 'TransientError', code: 'RATE_LIMITED', message: error.message }); } if (error.isRetryable) { throwActionError(ctx, { category: 'TransientError', code: 'TRANSIENT_FAILURE', message: error.message }); } throwActionError(ctx, { category: 'ActionError', code: 'INTERNAL_ERROR', message: error.message }); } throw error; } }) }); }