Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
191 lines
9.5 KiB
TypeScript
191 lines
9.5 KiB
TypeScript
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<string>([
|
|
...(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;
|
|
}
|
|
})
|
|
});
|
|
}
|