setup email

This commit is contained in:
phaichayon
2026-07-06 23:07:55 +07:00
parent d93f25e1ce
commit 18cc9bbfd0
30 changed files with 8907 additions and 156 deletions

View File

@@ -0,0 +1,167 @@
# Task P.7.2 Implementation Report
## Scope
This round implements the first working slice of the SMTP / Email Configuration Platform for ALLA OS.
Delivered scope:
- organization-scoped email configuration persistence
- encrypted SMTP password storage
- CRUD APIs for email configurations
- SMTP connection test
- send test email action
- setup readiness / verification integration
- setup wizard optional email status card
- dashboard-native email administration page
- notification runtime integration so email sending uses the stored organization configuration
## Review Summary
Reviewed before implementation:
- [AGENTS.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/AGENTS.md)
- [docs/standards/project-foundations.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/standards/project-foundations.md)
- [docs/standards/architecture-rules.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/standards/architecture-rules.md)
- [docs/standards/ui-ux-rules.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/standards/ui-ux-rules.md)
- [docs/standards/task-review-checklist.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/standards/task-review-checklist.md)
- [docs/setup/verification-matrix.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/setup/verification-matrix.md)
- [docs/setup/setup-wizard-blueprint.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/setup/setup-wizard-blueprint.md)
- [docs/setup/installation-profiles.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/setup/installation-profiles.md)
- [docs/implementation/task-d72-setup-readiness-verification-apis.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/implementation/task-d72-setup-readiness-verification-apis.md)
- [docs/implementation/task-d76-dashboard-setup-wizard-ui.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/implementation/task-d76-dashboard-setup-wizard-ui.md)
- [docs/implementation/task-f4-notification-framework-foundation.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/implementation/task-f4-notification-framework-foundation.md)
- [docs/implementation/task-p.7.1-implementation.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/implementation/task-p.7.1-implementation.md)
## Architecture Notes
The implementation intentionally extends the existing notification foundation instead of creating a second outbound-email stack.
Key decisions:
- persisted email configuration is organization-scoped in a new `email_configurations` table
- secret encryption is isolated in a reusable helper at [src/features/foundation/secrets/server/crypto.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/secrets/server/crypto.ts)
- the notification runtime keeps using the existing provider factory, but the email provider now resolves organization SMTP config from the new foundation
- setup readiness and setup verification both read from the same persisted source of truth instead of environment-only `SMTP_*` variables
- the setup wizard keeps email as an optional operator step, matching the task requirement that email must not block first-run setup by default
## Main Changes
### 1. Data Model And Secret Handling
Added:
- [src/db/schema.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/db/schema.ts)
- [drizzle/0001_yellow_wasp.sql](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/drizzle/0001_yellow_wasp.sql)
- [src/features/foundation/secrets/server/crypto.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/secrets/server/crypto.ts)
Behavior:
- SMTP passwords are stored only as encrypted ciphertext
- API responses never return decrypted or raw password values
- UI only receives `hasPassword`
- missing or weak `EMAIL_CONFIG_ENCRYPTION_KEY` blocks encryption-dependent save paths
### 2. Email Foundation Services
Added:
- [src/features/foundation/email/server/email-provider.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/email/server/email-provider.ts)
- [src/features/foundation/email/server/smtp-provider.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/email/server/smtp-provider.ts)
- [src/features/foundation/email/server/email-config.service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/email/server/email-config.service.ts)
- [src/features/foundation/email/server/email-test.service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/email/server/email-test.service.ts)
Behavior:
- create, update, list, fetch, and delete email configs
- track default / active config
- test connection and test send update `last_test_status`, `last_tested_at`, and safe `last_test_error`
- audit events are written for create, update, delete, and test outcomes
### 3. Notification Runtime Integration
Updated:
- [src/features/foundation/notifications/server/email-provider.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/notifications/server/email-provider.ts)
- [src/features/foundation/notifications/server/provider-factory.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/notifications/server/provider-factory.ts)
- [src/features/foundation/notifications/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/notifications/server/service.ts)
Behavior:
- email dispatches now resolve SMTP settings from the active organization configuration
- the provider factory now receives `organizationId`
- existing notification send / resend flows continue using the established foundation contract
### 4. Setup Integration
Updated:
- [src/features/setup/server/readiness.service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/setup/server/readiness.service.ts)
- [src/features/setup/server/verification.service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/setup/server/verification.service.ts)
- [src/features/setup/components/setup-wizard.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/setup/components/setup-wizard.tsx)
- [src/features/foundation/email/components/setup-email-card.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/email/components/setup-email-card.tsx)
Behavior:
- readiness and verification now check persisted organization email config
- no configuration returns `WARNING`, not `FAIL`, so setup remains non-blocking by default
- a lightweight email status card is shown inside the setup wizard and links to the email admin page
### 5. Admin APIs And UI
Added APIs:
- [src/app/api/notifications/email-configurations/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/notifications/email-configurations/route.ts)
- [src/app/api/notifications/email-configurations/[id]/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/notifications/email-configurations/[id]/route.ts)
- [src/app/api/notifications/email-configurations/[id]/test/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/notifications/email-configurations/[id]/test/route.ts)
- [src/app/api/notifications/email-configurations/[id]/send-test/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/notifications/email-configurations/[id]/send-test/route.ts)
Added client layer:
- [src/features/foundation/email/api/types.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/email/api/types.ts)
- [src/features/foundation/email/api/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/email/api/service.ts)
- [src/features/foundation/email/api/queries.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/email/api/queries.ts)
- [src/features/foundation/email/api/mutations.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/email/api/mutations.ts)
Added page:
- [src/app/dashboard/administration/email/page.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/dashboard/administration/email/page.tsx)
- [src/features/foundation/email/components/email-configuration-page.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/email/components/email-configuration-page.tsx)
- [src/config/nav-config.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/config/nav-config.ts)
UI notes:
- layout follows dashboard-native card rhythm rather than a separate visual system
- top summary area keeps status scannable
- quick verification panel gives one stable place for test-recipient entry
- config cards separate transport, sender identity, secret state, and test state clearly
- operator notes are intentionally calm and low-noise per task request
## Validation
Executed:
- `npm run typecheck -- --pretty false`
- `npm run db:generate`
- `npm run test:setup`
- `npx oxlint src/features/foundation/email src/features/foundation/secrets src/features/foundation/notifications/server/email-provider.ts src/features/foundation/notifications/server/provider-factory.ts src/features/setup/server/readiness.service.ts src/features/setup/server/verification.service.ts src/features/setup/components/setup-wizard.tsx src/app/api/notifications/email-configurations src/app/dashboard/administration/email/page.tsx src/config/nav-config.ts`
Results:
- TypeScript passed
- Drizzle migration generated successfully
- setup test suite passed
- targeted lint passed
## Known Gaps After This Round
- no dedicated profile-level rule yet to force email as a blocking setup requirement for selected installation profiles
- no password reset or invitation flow integration yet; this round only builds the platform and runtime foundation
- no seeded email templates or notification-template management UI specific to outbound email yet
- no per-configuration preview panel for rendered notification payloads yet
- no explicit soft-delete or archived state; delete currently removes the configuration row
## Notes
- This implementation assumes `EMAIL_CONFIG_ENCRYPTION_KEY` will be added to runtime environments before operators save SMTP passwords.
- Existing unrelated dirty files in the worktree were left untouched.

View File

@@ -0,0 +1,25 @@
CREATE TABLE "email_configurations" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"provider_type" text DEFAULT 'smtp' NOT NULL,
"name" text NOT NULL,
"host" text NOT NULL,
"port" integer DEFAULT 587 NOT NULL,
"secure" boolean DEFAULT false NOT NULL,
"username" text,
"password_encrypted" text,
"from_email" text NOT NULL,
"from_name" text,
"reply_to_email" text,
"is_default" boolean DEFAULT false NOT NULL,
"is_active" boolean DEFAULT true NOT NULL,
"last_test_status" text DEFAULT 'not_tested' NOT NULL,
"last_tested_at" timestamp with time zone,
"last_test_error" text,
"created_by" text NOT NULL,
"updated_by" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX "email_configurations_org_name_idx" ON "email_configurations" USING btree ("organization_id","name");

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,13 @@
"when": 1783326039269, "when": 1783326039269,
"tag": "0000_classy_unicorn", "tag": "0000_classy_unicorn",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1783350585715,
"tag": "0001_yellow_wasp",
"breakpoints": true
} }
] ]
} }

192
plans/task-p.7.2.md Normal file
View File

@@ -0,0 +1,192 @@
# Task P.7.2 SMTP / Email Configuration Platform
## Objective
Implement a secure SMTP / Email Configuration Platform for ALLA OS.
The goal is to let administrators configure, verify, and use outbound email safely for system notifications such as approval alerts, password reset, invitation emails, setup warnings, and future workflow notifications.
This task must integrate with the existing Setup Platform as an optional readiness/verification step.
Email configuration must not block First-run Setup unless explicitly required by the selected installation profile.
---
## Review Before Implementation
Review:
- AGENTS.md
- docs/standards/project-foundations.md
- docs/standards/architecture-rules.md
- docs/standards/ui-ux-rules.md
- docs/standards/task-review-checklist.md
- docs/setup/verification-matrix.md
- docs/setup/setup-wizard-blueprint.md
- docs/setup/installation-profiles.md
- src/features/setup/**
- src/features/foundation/notifications/**
- src/app/api/setup/readiness/route.ts
- src/app/api/setup/verify/route.ts
- src/db/schema.ts
- existing Auth.js password reset / invitation flow if present
---
## Scope
Create email configuration foundation.
Required features:
- SMTP configuration CRUD
- Secure secret handling
- SMTP connection test
- Send test email
- Email provider readiness check
- Integration with Setup Readiness / Verification
- Optional Setup Wizard step
- Audit-safe reporting
- No secret exposure in API responses
---
## Data Model
Add table:
email_configurations
Suggested fields:
id
organization_id nullable
provider_type
name
host
port
secure
username
password_encrypted
from_email
from_name
reply_to_email
is_default
is_active
last_test_status
last_tested_at
last_test_error
created_by
updated_by
created_at
updated_at
Provider type values:
smtp
future values:
sendgrid
ses
mailgun
microsoft_graph
---
## Secret Handling
SMTP password must never be stored as plain text.
Use existing encryption utility if available.
If none exists, create foundation crypto helper:
src/features/foundation/secrets/server/crypto.ts
Rules:
- encryption key from environment variable
- missing key blocks saving password
- API never returns password_encrypted
- UI shows password as "configured" or "not configured"
- password can be replaced, not read
Required environment:
EMAIL_CONFIG_ENCRYPTION_KEY
Suggested validation:
- minimum 32 bytes
- base64 or hex recommended
---
## Server Services
Create:
src/features/foundation/email/server/email-config.service.ts
src/features/foundation/email/server/email-test.service.ts
src/features/foundation/email/server/email-provider.ts
src/features/foundation/email/server/smtp-provider.ts
Responsibilities:
email-config.service.ts
- list configs
- get config
- create config
- update config
- delete/deactivate config
- set default config
- validate config
- mask secret output
email-test.service.ts
- test SMTP connection
- send test email
- update last_test_status
- update last_tested_at
- store safe last_test_error
email-provider.ts
- provider interface
smtp-provider.ts
- nodemailer implementation or existing mail library integration
---
## Provider Interface
```ts
export interface EmailProvider {
testConnection(config: EmailProviderConfig): Promise<EmailTestResult>;
sendEmail(input: SendEmailInput): Promise<SendEmailResult>;
}

View File

@@ -1,40 +1,26 @@
email,name,system_role,password,active_organization_code,image_url,reset_password email,name,system_role,password,active_organization_code,image_url,reset_password
super_admin@allaos.local,System Administrator,super_admin,ChangeMe123!,ALLA,,FALSE super_admin@allaos.local,System Administrator,super_admin,ChangeMe123!,ALLA,,FALSE
gm.uat@allaos.local,General Manager,user,ChangeMe123!,ALLA,,FALSE gm.uat@allaos.local,General Manager,user,ChangeMe123!,ALLA,,FALSE
ceo.uat@allaos.local,Chief Executive Officer,user,ChangeMe123!,ALLA,,FALSE ceo.uat@allaos.local,Chief Executive Officer,user,ChangeMe123!,ALLA,,FALSE
head_mk.uat@allaos.local,Head of Marketing,user,ChangeMe123!,ALLA,,FALSE head_mk.uat@allaos.local,Head of Marketing,user,ChangeMe123!,ALLA,,FALSE
mk.uat@allaos.local,Marketing User,user,ChangeMe123!,ALLA,,FALSE mk.uat@allaos.local,Marketing User,user,ChangeMe123!,ALLA,,FALSE
manager.uat@allaos.local,Sales Director,user,ChangeMe123!,ALLA,,FALSE manager.uat@allaos.local,Sales Director,user,ChangeMe123!,ALLA,,FALSE
mgr_crane.uat@allaos.local,Crane Sales Manager,user,ChangeMe123!,ALLA,,FALSE mgr_crane.uat@allaos.local,Crane Sales Manager,user,ChangeMe123!,ALLA,,FALSE
sales_crane.uat@allaos.local,Crane Sales,user,ChangeMe123!,ALLA,,FALSE sales_crane.uat@allaos.local,Crane Sales,user,ChangeMe123!,ALLA,,FALSE
mgr_dock.uat@allaos.local,Dock Door Sales Manager,user,ChangeMe123!,ALLA,,FALSE mgr_dock.uat@allaos.local,Dock Door Sales Manager,user,ChangeMe123!,ALLA,,FALSE
sales_dock.uat@allaos.local,Dock Door Sales,user,ChangeMe123!,ALLA,,FALSE sales_dock.uat@allaos.local,Dock Door Sales,user,ChangeMe123!,ALLA,,FALSE
mgr_solar.uat@allaos.local,Solar Sales Manager,user,ChangeMe123!,ALLA,,FALSE mgr_solar.uat@allaos.local,Solar Sales Manager,user,ChangeMe123!,ALLA,,FALSE
sales_solar.uat@allaos.local,Solar Sales,user,ChangeMe123!,ALLA,,FALSE sales_solar.uat@allaos.local,Solar Sales,user,ChangeMe123!,ALLA,,FALSE
sales.uat@allaos.local,General Sales,user,ChangeMe123!,ALLA,,FALSE sales.uat@allaos.local,General Sales,user,ChangeMe123!,ALLA,,FALSE
gm.onvalla@allaos.local,General Manager,user,ChangeMe123!,ONVALLA,,FALSE gm.onvalla@allaos.local,General Manager,user,ChangeMe123!,ONVALLA,,FALSE
ceo.onvalla@allaos.local,Chief Executive Officer,user,ChangeMe123!,ONVALLA,,FALSE ceo.onvalla@allaos.local,Chief Executive Officer,user,ChangeMe123!,ONVALLA,,FALSE
head_mk.onvalla@allaos.local,Head of Marketing,user,ChangeMe123!,ONVALLA,,FALSE head_mk.onvalla@allaos.local,Head of Marketing,user,ChangeMe123!,ONVALLA,,FALSE
mk.onvalla@allaos.local,Marketing User,user,ChangeMe123!,ONVALLA,,FALSE mk.onvalla@allaos.local,Marketing User,user,ChangeMe123!,ONVALLA,,FALSE
manager.onvalla@allaos.local,Sales Director,user,ChangeMe123!,ONVALLA,,FALSE manager.onvalla@allaos.local,Sales Director,user,ChangeMe123!,ONVALLA,,FALSE
mgr_crane.onvalla@allaos.local,Crane Sales Manager,user,ChangeMe123!,ONVALLA,,FALSE mgr_crane.onvalla@allaos.local,Crane Sales Manager,user,ChangeMe123!,ONVALLA,,FALSE
sales_crane.onvalla@allaos.local,Crane Sales,user,ChangeMe123!,ONVALLA,,FALSE sales_crane.onvalla@allaos.local,Crane Sales,user,ChangeMe123!,ONVALLA,,FALSE
mgr_dock.onvalla@allaos.local,Dock Door Sales Manager,user,ChangeMe123!,ONVALLA,,FALSE mgr_dock.onvalla@allaos.local,Dock Door Sales Manager,user,ChangeMe123!,ONVALLA,,FALSE
sales_dock.onvalla@allaos.local,Dock Door Sales,user,ChangeMe123!,ONVALLA,,FALSE sales_dock.onvalla@allaos.local,Dock Door Sales,user,ChangeMe123!,ONVALLA,,FALSE
mgr_solar.onvalla@allaos.local,Solar Sales Manager,user,ChangeMe123!,ONVALLA,,FALSE mgr_solar.onvalla@allaos.local,Solar Sales Manager,user,ChangeMe123!,ONVALLA,,FALSE
sales_solar.onvalla@allaos.local,Solar Sales,user,ChangeMe123!,ONVALLA,,FALSE sales_solar.onvalla@allaos.local,Solar Sales,user,ChangeMe123!,ONVALLA,,FALSE
sales.onvalla@allaos.local,General Sales,user,ChangeMe123!,ONVALLA,,FALSE sales.onvalla@allaos.local,General Sales,user,ChangeMe123!,ONVALLA,,FALSE
1 email name system_role password active_organization_code image_url reset_password
2 super_admin@allaos.local System Administrator super_admin ChangeMe123! ALLA FALSE
gm.uat@allaos.local General Manager user ChangeMe123! ALLA FALSE
3 ceo.uat@allaos.local gm.uat@allaos.local Chief Executive Officer General Manager user ChangeMe123! ALLA FALSE
4 head_mk.uat@allaos.local ceo.uat@allaos.local Head of Marketing Chief Executive Officer user ChangeMe123! ALLA FALSE
mk.uat@allaos.local Marketing User user ChangeMe123! ALLA FALSE
5 manager.uat@allaos.local head_mk.uat@allaos.local Sales Director Head of Marketing user ChangeMe123! ALLA FALSE
6 mgr_crane.uat@allaos.local mk.uat@allaos.local Crane Sales Manager Marketing User user ChangeMe123! ALLA FALSE
sales_crane.uat@allaos.local Crane Sales user ChangeMe123! ALLA FALSE
7 mgr_dock.uat@allaos.local manager.uat@allaos.local Dock Door Sales Manager Sales Director user ChangeMe123! ALLA FALSE
sales_dock.uat@allaos.local Dock Door Sales user ChangeMe123! ALLA FALSE
8 mgr_solar.uat@allaos.local mgr_crane.uat@allaos.local Solar Sales Manager Crane Sales Manager user ChangeMe123! ALLA FALSE
9 sales_solar.uat@allaos.local sales_crane.uat@allaos.local Solar Sales Crane Sales user ChangeMe123! ALLA FALSE
sales.uat@allaos.local General Sales user ChangeMe123! ALLA FALSE
10 gm.onvalla@allaos.local mgr_dock.uat@allaos.local General Manager Dock Door Sales Manager user ChangeMe123! ONVALLA ALLA FALSE
11 ceo.onvalla@allaos.local sales_dock.uat@allaos.local Chief Executive Officer Dock Door Sales user ChangeMe123! ONVALLA ALLA FALSE
head_mk.onvalla@allaos.local Head of Marketing user ChangeMe123! ONVALLA FALSE
12 mk.onvalla@allaos.local mgr_solar.uat@allaos.local Marketing User Solar Sales Manager user ChangeMe123! ONVALLA ALLA FALSE
13 manager.onvalla@allaos.local sales_solar.uat@allaos.local Sales Director Solar Sales user ChangeMe123! ONVALLA ALLA FALSE
mgr_crane.onvalla@allaos.local Crane Sales Manager user ChangeMe123! ONVALLA FALSE
14 sales_crane.onvalla@allaos.local sales.uat@allaos.local Crane Sales General Sales user ChangeMe123! ONVALLA ALLA FALSE
mgr_dock.onvalla@allaos.local Dock Door Sales Manager user ChangeMe123! ONVALLA FALSE
15 sales_dock.onvalla@allaos.local gm.onvalla@allaos.local Dock Door Sales General Manager user ChangeMe123! ONVALLA FALSE
16 mgr_solar.onvalla@allaos.local ceo.onvalla@allaos.local Solar Sales Manager Chief Executive Officer user ChangeMe123! ONVALLA FALSE
sales_solar.onvalla@allaos.local Solar Sales user ChangeMe123! ONVALLA FALSE
17 sales.onvalla@allaos.local head_mk.onvalla@allaos.local General Sales Head of Marketing user ChangeMe123! ONVALLA FALSE
18 mk.onvalla@allaos.local Marketing User user ChangeMe123! ONVALLA FALSE
19 manager.onvalla@allaos.local Sales Director user ChangeMe123! ONVALLA FALSE
20 mgr_crane.onvalla@allaos.local Crane Sales Manager user ChangeMe123! ONVALLA FALSE
21 sales_crane.onvalla@allaos.local Crane Sales user ChangeMe123! ONVALLA FALSE
22 mgr_dock.onvalla@allaos.local Dock Door Sales Manager user ChangeMe123! ONVALLA FALSE
23 sales_dock.onvalla@allaos.local Dock Door Sales user ChangeMe123! ONVALLA FALSE
24 mgr_solar.onvalla@allaos.local Solar Sales Manager user ChangeMe123! ONVALLA FALSE
25 sales_solar.onvalla@allaos.local Solar Sales user ChangeMe123! ONVALLA FALSE
26 sales.onvalla@allaos.local General Sales user ChangeMe123! ONVALLA FALSE

View File

@@ -0,0 +1,116 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import {
deleteEmailConfiguration,
getEmailConfiguration,
updateEmailConfiguration,
} from '@/features/foundation/email/server/email-config.service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
const emailConfigurationSchema = z.object({
providerType: z.literal('smtp').default('smtp'),
name: z.string().trim().min(1).max(120),
host: z.string().trim().min(1).max(255),
port: z.number().int().min(1).max(65535),
secure: z.boolean(),
username: z.string().trim().max(255).nullable().optional(),
password: z.string().trim().max(1000).nullable().optional(),
clearPassword: z.boolean().optional(),
fromEmail: z.string().trim().email(),
fromName: z.string().trim().max(255).nullable().optional(),
replyToEmail: z.string().trim().email().nullable().optional(),
isDefault: z.boolean().optional(),
isActive: z.boolean().optional(),
});
export async function GET(
_request: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const { organization } = await requireOrganizationAccess({
permission: PERMISSIONS.notificationsAdmin,
});
const { id } = await context.params;
const configuration = await getEmailConfiguration(organization.id, id);
if (!configuration) {
return NextResponse.json({ message: 'Email configuration was not found.' }, { status: 404 });
}
return NextResponse.json(configuration);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load email configuration' }, { status: 500 });
}
}
export async function PATCH(
request: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.notificationsAdmin,
});
const { id } = await context.params;
const payload = emailConfigurationSchema.parse(await request.json());
return NextResponse.json(
await updateEmailConfiguration({
organizationId: organization.id,
userId: session.user.id,
id,
values: payload,
})
);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ message: 'Invalid email configuration payload', errors: error.flatten() },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to update email configuration' }, { status: 500 });
}
}
export async function DELETE(
_request: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.notificationsAdmin,
});
const { id } = await context.params;
await deleteEmailConfiguration({
organizationId: organization.id,
userId: session.user.id,
id,
});
return NextResponse.json({ success: true });
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to delete email configuration' }, { status: 500 });
}
}

View File

@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { sendTestEmail } from '@/features/foundation/email/server/email-test.service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
const sendTestEmailSchema = z.object({
recipientEmail: z.string().trim().email(),
});
export async function POST(
request: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.notificationsAdmin,
});
const { id } = await context.params;
const payload = sendTestEmailSchema.parse(await request.json());
return NextResponse.json(
await sendTestEmail({
organizationId: organization.id,
userId: session.user.id,
configurationId: id,
recipientEmail: payload.recipientEmail,
})
);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ message: 'Invalid test email payload', errors: error.flatten() },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to send test email' }, { status: 500 });
}
}

View File

@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server';
import { testEmailConfigurationConnection } from '@/features/foundation/email/server/email-test.service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
export async function POST(
_request: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.notificationsAdmin,
});
const { id } = await context.params;
return NextResponse.json(
await testEmailConfigurationConnection({
organizationId: organization.id,
userId: session.user.id,
configurationId: id,
})
);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to test email configuration' }, { status: 500 });
}
}

View File

@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import {
createEmailConfiguration,
listEmailConfigurations,
} from '@/features/foundation/email/server/email-config.service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
const emailConfigurationSchema = z.object({
providerType: z.literal('smtp').default('smtp'),
name: z.string().trim().min(1).max(120),
host: z.string().trim().min(1).max(255),
port: z.number().int().min(1).max(65535),
secure: z.boolean(),
username: z.string().trim().max(255).nullable().optional(),
password: z.string().trim().max(1000).nullable().optional(),
clearPassword: z.boolean().optional(),
fromEmail: z.string().trim().email(),
fromName: z.string().trim().max(255).nullable().optional(),
replyToEmail: z.string().trim().email().nullable().optional(),
isDefault: z.boolean().optional(),
isActive: z.boolean().optional(),
});
export async function GET() {
try {
const { organization } = await requireOrganizationAccess({
permission: PERMISSIONS.notificationsAdmin,
});
return NextResponse.json(await listEmailConfigurations(organization.id));
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load email configurations' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.notificationsAdmin,
});
const payload = emailConfigurationSchema.parse(await request.json());
return NextResponse.json(
await createEmailConfiguration({
organizationId: organization.id,
userId: session.user.id,
values: payload,
}),
{ status: 201 }
);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ message: 'Invalid email configuration payload', errors: error.flatten() },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to create email configuration' }, { status: 500 });
}
}

View File

@@ -0,0 +1,27 @@
import { auth } from '@/auth';
import PageContainer from '@/components/layout/page-container';
import { EmailConfigurationPage } from '@/features/foundation/email/components/email-configuration-page';
export const metadata = {
title: 'Dashboard: Email Configuration',
};
export default async function AdministrationEmailPage() {
const session = await auth();
const canAccess = session?.user?.systemRole === 'super_admin';
return (
<PageContainer
pageTitle='Administration Email Configuration'
pageDescription='Configure secure SMTP delivery for setup checks, notifications, approvals, and future email workflows.'
access={canAccess}
accessFallback={
<div className='rounded-lg border border-dashed p-8 text-center text-muted-foreground'>
Email configuration requires a super admin account.
</div>
}
>
<EmailConfigurationPage />
</PageContainer>
);
}

View File

@@ -178,16 +178,24 @@ import { NavGroup } from "@/types";
items: [], items: [],
access: { systemRole: "super_admin" }, access: { systemRole: "super_admin" },
}, },
{ {
title: "Setup Wizard", title: "Setup Wizard",
url: "/dashboard/administration/setup", url: "/dashboard/administration/setup",
icon: "settings", icon: "settings",
isActive: false, isActive: false,
items: [], items: [],
access: { systemRole: "super_admin" }, access: { systemRole: "super_admin" },
}, },
{ {
title: "Teams", title: "Email Delivery",
url: "/dashboard/administration/email",
icon: "settings",
isActive: false,
items: [],
access: { systemRole: "super_admin" },
},
{
title: "Teams",
url: "/dashboard/workspaces/team", url: "/dashboard/workspaces/team",
icon: "teams", icon: "teams",
isActive: false, isActive: false,

View File

@@ -265,6 +265,39 @@ export const documentSequences = pgTable(
}) })
); );
export const emailConfigurations = pgTable(
'email_configurations',
{
id: text('id').primaryKey(),
organizationId: text('organization_id').notNull(),
providerType: text('provider_type').default('smtp').notNull(),
name: text('name').notNull(),
host: text('host').notNull(),
port: integer('port').default(587).notNull(),
secure: boolean('secure').default(false).notNull(),
username: text('username'),
passwordEncrypted: text('password_encrypted'),
fromEmail: text('from_email').notNull(),
fromName: text('from_name'),
replyToEmail: text('reply_to_email'),
isDefault: boolean('is_default').default(false).notNull(),
isActive: boolean('is_active').default(true).notNull(),
lastTestStatus: text('last_test_status').default('not_tested').notNull(),
lastTestedAt: timestamp('last_tested_at', { withTimezone: true }),
lastTestError: text('last_test_error'),
createdBy: text('created_by').notNull(),
updatedBy: text('updated_by').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
},
(table) => ({
organizationNameIdx: uniqueIndex('email_configurations_org_name_idx').on(
table.organizationId,
table.name
)
})
);
export const trAuditLogs = pgTable('tr_audit_logs', { export const trAuditLogs = pgTable('tr_audit_logs', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
organizationId: text('organization_id').notNull(), organizationId: text('organization_id').notNull(),

View File

@@ -0,0 +1,47 @@
import { mutationOptions } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/query-client';
import {
createEmailConfiguration,
deleteEmailConfiguration,
sendEmailConfigurationTestEmail,
testEmailConfigurationConnection,
updateEmailConfiguration,
} from './service';
import { emailConfigurationKeys } from './queries';
import type { EmailConfigurationPayload, SendTestEmailPayload } from './types';
function invalidateEmailConfigurations() {
getQueryClient().invalidateQueries({ queryKey: emailConfigurationKeys.all });
}
export const createEmailConfigurationMutation = mutationOptions({
mutationFn: (payload: EmailConfigurationPayload) => createEmailConfiguration(payload),
onSuccess: invalidateEmailConfigurations,
});
export const updateEmailConfigurationMutation = mutationOptions({
mutationFn: ({ id, payload }: { id: string; payload: EmailConfigurationPayload }) =>
updateEmailConfiguration(id, payload),
onSuccess: invalidateEmailConfigurations,
});
export const deleteEmailConfigurationMutation = mutationOptions({
mutationFn: (id: string) => deleteEmailConfiguration(id),
onSuccess: invalidateEmailConfigurations,
});
export const testEmailConfigurationConnectionMutation = mutationOptions({
mutationFn: (id: string) => testEmailConfigurationConnection(id),
onSuccess: invalidateEmailConfigurations,
});
export const sendEmailConfigurationTestEmailMutation = mutationOptions({
mutationFn: ({
id,
payload,
}: {
id: string;
payload: SendTestEmailPayload;
}) => sendEmailConfigurationTestEmail(id, payload),
onSuccess: invalidateEmailConfigurations,
});

View File

@@ -0,0 +1,14 @@
import { queryOptions } from '@tanstack/react-query';
import { listEmailConfigurations } from './service';
export const emailConfigurationKeys = {
all: ['email-configurations'] as const,
lists: () => [...emailConfigurationKeys.all, 'list'] as const,
list: () => [...emailConfigurationKeys.lists()] as const,
};
export const emailConfigurationsQueryOptions = () =>
queryOptions({
queryKey: emailConfigurationKeys.list(),
queryFn: () => listEmailConfigurations(),
});

View File

@@ -0,0 +1,60 @@
import { apiClient } from '@/lib/api-client';
import type {
EmailConfigurationPayload,
EmailConfigurationSummary,
EmailConnectionTestResult,
SendTestEmailPayload,
SendTestEmailResult,
} from './types';
const EMAIL_CONFIG_ENDPOINT = '/notifications/email-configurations';
export async function listEmailConfigurations(): Promise<EmailConfigurationSummary[]> {
return apiClient<EmailConfigurationSummary[]>(EMAIL_CONFIG_ENDPOINT);
}
export async function createEmailConfiguration(
payload: EmailConfigurationPayload
): Promise<EmailConfigurationSummary> {
return apiClient<EmailConfigurationSummary>(EMAIL_CONFIG_ENDPOINT, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
});
}
export async function updateEmailConfiguration(
id: string,
payload: EmailConfigurationPayload
): Promise<EmailConfigurationSummary> {
return apiClient<EmailConfigurationSummary>(`${EMAIL_CONFIG_ENDPOINT}/${id}`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
});
}
export async function deleteEmailConfiguration(id: string): Promise<{ success: true }> {
return apiClient<{ success: true }>(`${EMAIL_CONFIG_ENDPOINT}/${id}`, {
method: 'DELETE',
});
}
export async function testEmailConfigurationConnection(
id: string
): Promise<EmailConnectionTestResult> {
return apiClient<EmailConnectionTestResult>(`${EMAIL_CONFIG_ENDPOINT}/${id}/test`, {
method: 'POST',
});
}
export async function sendEmailConfigurationTestEmail(
id: string,
payload: SendTestEmailPayload
): Promise<SendTestEmailResult> {
return apiClient<SendTestEmailResult>(`${EMAIL_CONFIG_ENDPOINT}/${id}/send-test`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
});
}

View File

@@ -0,0 +1,58 @@
export type EmailConfigurationProviderType = 'smtp';
export type EmailConfigurationTestStatus = 'not_tested' | 'passed' | 'failed';
export interface EmailConfigurationSummary {
id: string;
organizationId: string;
providerType: EmailConfigurationProviderType;
name: string;
host: string;
port: number;
secure: boolean;
username: string | null;
fromEmail: string;
fromName: string | null;
replyToEmail: string | null;
isDefault: boolean;
isActive: boolean;
hasPassword: boolean;
lastTestStatus: EmailConfigurationTestStatus;
lastTestedAt: string | null;
lastTestError: string | null;
createdBy: string;
updatedBy: string;
createdAt: string;
updatedAt: string;
}
export interface EmailConfigurationPayload {
providerType: EmailConfigurationProviderType;
name: string;
host: string;
port: number;
secure: boolean;
username?: string | null;
password?: string | null;
clearPassword?: boolean;
fromEmail: string;
fromName?: string | null;
replyToEmail?: string | null;
isDefault?: boolean;
isActive?: boolean;
}
export interface EmailConnectionTestResult {
ok: boolean;
detail?: string;
}
export interface SendTestEmailPayload {
recipientEmail: string;
}
export interface SendTestEmailResult {
providerMessageId: string | null;
acceptedRecipients: string[];
rejectedRecipients: string[];
metadata?: Record<string, unknown>;
}

View File

@@ -0,0 +1,677 @@
'use client';
import Link from 'next/link';
import { useEffect, useMemo, useState } from 'react';
import { z } from 'zod';
import { useMutation, useQuery } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Icons } from '@/components/icons';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { Separator } from '@/components/ui/separator';
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
import { formatDateTime } from '@/lib/date-format';
import { cn } from '@/lib/utils';
import {
createEmailConfigurationMutation,
deleteEmailConfigurationMutation,
sendEmailConfigurationTestEmailMutation,
testEmailConfigurationConnectionMutation,
updateEmailConfigurationMutation,
} from '../api/mutations';
import { emailConfigurationsQueryOptions } from '../api/queries';
import type { EmailConfigurationPayload, EmailConfigurationSummary } from '../api/types';
const EMPTY_EMAIL_CONFIGURATIONS: EmailConfigurationSummary[] = [];
const emailConfigurationFormSchema = z.object({
providerType: z.literal('smtp'),
name: z.string().trim().min(1, 'Configuration name is required.'),
host: z.string().trim().min(1, 'SMTP host is required.'),
port: z
.string()
.trim()
.min(1, 'Port is required.')
.refine((value) => {
const port = Number(value);
return Number.isInteger(port) && port > 0 && port <= 65535;
}, 'Port must be between 1 and 65535.'),
secure: z.boolean(),
username: z.string().trim().max(255).optional(),
password: z.string().trim().max(1000).optional(),
clearPassword: z.boolean(),
fromEmail: z.string().trim().email('From email must be valid.'),
fromName: z.string().trim().max(255).optional(),
replyToEmail: z
.string()
.trim()
.optional()
.refine((value) => !value || z.string().email().safeParse(value).success, 'Reply-to email must be valid.'),
isDefault: z.boolean(),
isActive: z.boolean(),
});
type EmailConfigurationFormValues = z.infer<typeof emailConfigurationFormSchema>;
function buildDefaultValues(
configuration?: EmailConfigurationSummary
): EmailConfigurationFormValues {
return {
providerType: 'smtp',
name: configuration?.name ?? '',
host: configuration?.host ?? '',
port: String(configuration?.port ?? 587),
secure: configuration?.secure ?? false,
username: configuration?.username ?? '',
password: '',
clearPassword: false,
fromEmail: configuration?.fromEmail ?? '',
fromName: configuration?.fromName ?? '',
replyToEmail: configuration?.replyToEmail ?? '',
isDefault: configuration?.isDefault ?? false,
isActive: configuration?.isActive ?? true,
};
}
function statusBadgeVariant(status: EmailConfigurationSummary['lastTestStatus']) {
if (status === 'failed') return 'destructive';
if (status === 'passed') return 'default';
return 'secondary';
}
function SummaryMetric({ label, value, hint }: { label: string; value: string; hint: string }) {
return (
<div className='rounded-2xl border border-border/70 bg-background/85 p-4 shadow-sm'>
<div className='text-xs font-medium uppercase tracking-[0.14em] text-muted-foreground'>
{label}
</div>
<div className='mt-2 text-2xl font-semibold text-foreground'>{value}</div>
<div className='mt-1 text-sm text-muted-foreground'>{hint}</div>
</div>
);
}
function SecurityNote({ title, description }: { title: string; description: string }) {
return (
<div className='rounded-2xl border border-border/60 bg-background/80 p-4'>
<div className='flex items-start gap-3'>
<div className='rounded-full bg-primary/10 p-2 text-primary'>
<Icons.lock className='h-4 w-4' />
</div>
<div className='space-y-1'>
<div className='font-medium'>{title}</div>
<div className='text-sm text-muted-foreground'>{description}</div>
</div>
</div>
</div>
);
}
function ConfigCard({
configuration,
sendRecipientEmail,
onEdit,
onDelete,
onTestConnection,
onSendTest,
isDeleting,
isTesting,
isSending,
}: {
configuration: EmailConfigurationSummary;
sendRecipientEmail: string;
onEdit: (configuration: EmailConfigurationSummary) => void;
onDelete: (configuration: EmailConfigurationSummary) => void;
onTestConnection: (configuration: EmailConfigurationSummary) => void;
onSendTest: (configuration: EmailConfigurationSummary) => void;
isDeleting: boolean;
isTesting: boolean;
isSending: boolean;
}) {
return (
<Card className='overflow-hidden border-border/70 bg-gradient-to-br from-background via-background to-muted/30 shadow-sm'>
<CardHeader className='gap-4'>
<div className='flex flex-wrap items-start justify-between gap-3'>
<div className='space-y-2'>
<div className='flex flex-wrap items-center gap-2'>
<CardTitle className='text-xl'>{configuration.name}</CardTitle>
{configuration.isDefault ? <Badge>Default</Badge> : null}
<Badge variant={configuration.isActive ? 'outline' : 'secondary'}>
{configuration.isActive ? 'Active' : 'Inactive'}
</Badge>
</div>
<CardDescription>
{configuration.host}:{configuration.port} · {configuration.secure ? 'TLS/SSL' : 'STARTTLS / plain'}
</CardDescription>
</div>
<Badge variant={statusBadgeVariant(configuration.lastTestStatus)}>
{configuration.lastTestStatus === 'not_tested'
? 'Not tested'
: configuration.lastTestStatus === 'passed'
? 'SMTP ready'
: 'Connection failed'}
</Badge>
</div>
</CardHeader>
<CardContent className='space-y-5'>
<div className='grid gap-3 md:grid-cols-2 xl:grid-cols-4'>
<div className='rounded-xl border border-border/60 bg-background/75 p-3'>
<div className='text-xs uppercase tracking-[0.14em] text-muted-foreground'>From</div>
<div className='mt-1 text-sm font-medium'>{configuration.fromEmail}</div>
<div className='text-sm text-muted-foreground'>{configuration.fromName || 'No display name'}</div>
</div>
<div className='rounded-xl border border-border/60 bg-background/75 p-3'>
<div className='text-xs uppercase tracking-[0.14em] text-muted-foreground'>Auth</div>
<div className='mt-1 text-sm font-medium'>{configuration.username || 'Anonymous / relay'}</div>
<div className='text-sm text-muted-foreground'>
Password {configuration.hasPassword ? 'configured' : 'not configured'}
</div>
</div>
<div className='rounded-xl border border-border/60 bg-background/75 p-3'>
<div className='text-xs uppercase tracking-[0.14em] text-muted-foreground'>Reply-to</div>
<div className='mt-1 text-sm font-medium'>
{configuration.replyToEmail || 'Same as from address'}
</div>
</div>
<div className='rounded-xl border border-border/60 bg-background/75 p-3'>
<div className='text-xs uppercase tracking-[0.14em] text-muted-foreground'>Last verification</div>
<div className='mt-1 text-sm font-medium'>
{configuration.lastTestedAt ? formatDateTime(configuration.lastTestedAt) : 'Never tested'}
</div>
</div>
</div>
{configuration.lastTestError ? (
<Alert variant='destructive'>
<Icons.alertCircle className='h-4 w-4' />
<AlertTitle>Last SMTP error</AlertTitle>
<AlertDescription>{configuration.lastTestError}</AlertDescription>
</Alert>
) : null}
<div className='flex flex-wrap gap-2'>
<Button type='button' variant='outline' onClick={() => onEdit(configuration)}>
<Icons.settings className='mr-2 h-4 w-4' />
Edit
</Button>
<Button
type='button'
variant='outline'
onClick={() => onTestConnection(configuration)}
isLoading={isTesting}
>
<Icons.check className='mr-2 h-4 w-4' />
Test connection
</Button>
<Button
type='button'
onClick={() => onSendTest(configuration)}
isLoading={isSending}
disabled={!sendRecipientEmail.trim()}
>
<Icons.send className='mr-2 h-4 w-4' />
Send test email
</Button>
<Button
type='button'
variant='outline'
onClick={() => onDelete(configuration)}
isLoading={isDeleting}
>
<Icons.close className='mr-2 h-4 w-4' />
Delete
</Button>
</div>
</CardContent>
</Card>
);
}
export function EmailConfigurationPage() {
const [sheetOpen, setSheetOpen] = useState(false);
const [editingConfiguration, setEditingConfiguration] = useState<EmailConfigurationSummary | null>(null);
const [sendRecipientEmail, setSendRecipientEmail] = useState('');
const emailConfigurationsQuery = useQuery(emailConfigurationsQueryOptions());
const createMutation = useMutation(createEmailConfigurationMutation);
const updateMutation = useMutation(updateEmailConfigurationMutation);
const deleteMutation = useMutation(deleteEmailConfigurationMutation);
const testConnectionMutation = useMutation(testEmailConfigurationConnectionMutation);
const sendTestEmailMutation = useMutation(sendEmailConfigurationTestEmailMutation);
const configurations = emailConfigurationsQuery.data ?? EMPTY_EMAIL_CONFIGURATIONS;
const summary = useMemo(() => {
const activeCount = configurations.filter((item) => item.isActive).length;
const passingCount = configurations.filter((item) => item.lastTestStatus === 'passed').length;
const defaultConfig = configurations.find((item) => item.isDefault) ?? null;
return {
total: configurations.length,
activeCount,
passingCount,
defaultName: defaultConfig?.name ?? 'Not selected',
};
}, [configurations]);
const defaultValues = useMemo(
() => buildDefaultValues(editingConfiguration ?? undefined),
[editingConfiguration]
);
const { FormTextField, FormSwitchField } = useFormFields<EmailConfigurationFormValues>();
const form = useAppForm({
defaultValues,
validators: {
onSubmit: emailConfigurationFormSchema,
},
onSubmit: async ({ value }) => {
const payload: EmailConfigurationPayload = {
providerType: 'smtp',
name: value.name.trim(),
host: value.host.trim(),
port: Number(value.port),
secure: value.secure,
username: value.username?.trim() || null,
password: value.password?.trim() || undefined,
clearPassword: value.clearPassword,
fromEmail: value.fromEmail.trim().toLowerCase(),
fromName: value.fromName?.trim() || null,
replyToEmail: value.replyToEmail?.trim() || null,
isDefault: value.isDefault,
isActive: value.isActive,
};
if (editingConfiguration) {
await updateMutation.mutateAsync({
id: editingConfiguration.id,
payload,
});
toast.success('Email configuration updated.');
} else {
await createMutation.mutateAsync(payload);
toast.success('Email configuration created.');
}
setSheetOpen(false);
setEditingConfiguration(null);
},
});
useEffect(() => {
if (sheetOpen) {
form.reset(defaultValues);
}
}, [defaultValues, form, sheetOpen]);
async function handleDelete(configuration: EmailConfigurationSummary) {
try {
await deleteMutation.mutateAsync(configuration.id);
toast.success(`Removed ${configuration.name}.`);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Unable to delete email configuration.');
}
}
async function handleTestConnection(configuration: EmailConfigurationSummary) {
try {
const result = await testConnectionMutation.mutateAsync(configuration.id);
if (!result.ok) {
toast.error(result.detail ?? 'SMTP connection failed.');
return;
}
toast.success(`SMTP connection succeeded for ${configuration.name}.`);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Unable to test SMTP connection.');
}
}
async function handleSendTest(configuration: EmailConfigurationSummary) {
try {
const result = await sendTestEmailMutation.mutateAsync({
id: configuration.id,
payload: {
recipientEmail: sendRecipientEmail.trim().toLowerCase(),
},
});
if (result.rejectedRecipients.length > 0) {
toast.error(`SMTP rejected: ${result.rejectedRecipients.join(', ')}`);
return;
}
toast.success(`Test email queued to ${sendRecipientEmail.trim().toLowerCase()}.`);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Unable to send test email.');
}
}
const saving = createMutation.isPending || updateMutation.isPending;
return (
<div className='space-y-6'>
<section className='overflow-hidden rounded-[28px] border border-border/60 bg-[linear-gradient(135deg,rgba(240,246,255,0.9),rgba(255,255,255,0.96),rgba(243,247,252,0.92))] shadow-sm'>
<div className='grid gap-6 px-6 py-6 lg:grid-cols-[1.4fr_0.9fr] lg:px-8'>
<div className='space-y-5'>
<div className='inline-flex items-center gap-2 rounded-full border border-primary/20 bg-primary/5 px-3 py-1 text-xs font-medium tracking-[0.16em] text-primary uppercase'>
<Icons.send className='h-3.5 w-3.5' />
Email delivery center
</div>
<div className='space-y-3'>
<h2 className='max-w-3xl text-3xl font-semibold tracking-tight text-foreground'>
Manage SMTP delivery in one calm, auditable place.
</h2>
<p className='max-w-2xl text-sm leading-6 text-muted-foreground'>
Configure secure outbound email for approvals, password recovery, invitations, and setup warnings.
Secrets stay encrypted at rest and the UI only exposes safe operational state.
</p>
</div>
<div className='grid gap-3 sm:grid-cols-2 xl:grid-cols-4'>
<SummaryMetric
label='Configurations'
value={String(summary.total)}
hint='Stored SMTP profiles'
/>
<SummaryMetric
label='Active'
value={String(summary.activeCount)}
hint='Eligible for delivery'
/>
<SummaryMetric
label='Passing'
value={String(summary.passingCount)}
hint='Last SMTP test passed'
/>
<SummaryMetric
label='Default route'
value={summary.defaultName}
hint='Used by notification runtime'
/>
</div>
</div>
<Card className='border-border/60 bg-background/80 shadow-none'>
<CardHeader>
<CardTitle>Quick verification</CardTitle>
<CardDescription>
Use one test recipient while reviewing multiple SMTP profiles.
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<div className='space-y-2'>
<label className='text-sm font-medium' htmlFor='email-test-recipient'>
Test recipient
</label>
<Input
id='email-test-recipient'
type='email'
value={sendRecipientEmail}
onChange={(event) => setSendRecipientEmail(event.target.value)}
placeholder='ops@example.com'
/>
</div>
<Separator />
<div className='space-y-3 text-sm text-muted-foreground'>
<div className='flex items-start gap-3'>
<Icons.check className='mt-0.5 h-4 w-4 text-primary' />
<span>Connection test updates the configuration health state without exposing the password.</span>
</div>
<div className='flex items-start gap-3'>
<Icons.lock className='mt-0.5 h-4 w-4 text-primary' />
<span>Stored passwords can be replaced or cleared, but are never returned from the API.</span>
</div>
<div className='flex items-start gap-3'>
<Icons.settings className='mt-0.5 h-4 w-4 text-primary' />
<span>Setup wizard and notification runtime read the same organization-level SMTP source.</span>
</div>
</div>
<div className='flex flex-wrap gap-2'>
<Button
type='button'
onClick={() => {
setEditingConfiguration(null);
setSheetOpen(true);
}}
>
<Icons.check className='mr-2 h-4 w-4' />
Add configuration
</Button>
<Button asChild type='button' variant='outline'>
<Link href='/dashboard/administration/setup'>Open setup wizard</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
</section>
<div className='grid gap-6 xl:grid-cols-[minmax(0,1fr)_320px]'>
<div className='space-y-4'>
{emailConfigurationsQuery.isLoading ? (
<Card>
<CardContent className='flex items-center gap-3 p-6 text-sm text-muted-foreground'>
<Icons.spinner className='h-4 w-4 animate-spin' />
Loading email configurations...
</CardContent>
</Card>
) : null}
{emailConfigurationsQuery.isError ? (
<Alert variant='destructive'>
<Icons.alertCircle className='h-4 w-4' />
<AlertTitle>Unable to load SMTP profiles</AlertTitle>
<AlertDescription>
{emailConfigurationsQuery.error instanceof Error
? emailConfigurationsQuery.error.message
: 'Unknown email configuration error.'}
</AlertDescription>
</Alert>
) : null}
{!emailConfigurationsQuery.isLoading && configurations.length === 0 ? (
<Card className='border-dashed'>
<CardContent className='flex flex-col items-center gap-3 px-6 py-12 text-center'>
<div className='rounded-full bg-primary/10 p-3 text-primary'>
<Icons.send className='h-6 w-6' />
</div>
<div className='space-y-2'>
<div className='text-lg font-semibold'>No SMTP configuration yet</div>
<div className='max-w-xl text-sm text-muted-foreground'>
Add a profile to enable outbound email for system notifications, approvals, and future workflow messages.
</div>
</div>
<Button
type='button'
onClick={() => {
setEditingConfiguration(null);
setSheetOpen(true);
}}
>
<Icons.check className='mr-2 h-4 w-4' />
Create first configuration
</Button>
</CardContent>
</Card>
) : null}
{configurations.map((configuration) => (
<ConfigCard
key={configuration.id}
configuration={configuration}
sendRecipientEmail={sendRecipientEmail}
onEdit={(item) => {
setEditingConfiguration(item);
setSheetOpen(true);
}}
onDelete={handleDelete}
onTestConnection={handleTestConnection}
onSendTest={handleSendTest}
isDeleting={deleteMutation.isPending && deleteMutation.variables === configuration.id}
isTesting={
testConnectionMutation.isPending &&
testConnectionMutation.variables === configuration.id
}
isSending={
sendTestEmailMutation.isPending &&
sendTestEmailMutation.variables?.id === configuration.id
}
/>
))}
</div>
<aside className='space-y-4'>
<Card className='border-border/70 bg-muted/20'>
<CardHeader>
<CardTitle>Operator notes</CardTitle>
<CardDescription>
A calm checklist for setup and post-go-live operations.
</CardDescription>
</CardHeader>
<CardContent className='space-y-3'>
<SecurityNote
title='Encrypt before save'
description='Saving a password requires EMAIL_CONFIG_ENCRYPTION_KEY on the server.'
/>
<SecurityNote
title='Choose one default route'
description='The notification runtime will prefer the organization default SMTP profile.'
/>
<SecurityNote
title='Verify after every credential change'
description='Run connection test and test email again whenever host, username, or password changes.'
/>
</CardContent>
</Card>
</aside>
</div>
<Sheet
open={sheetOpen}
onOpenChange={(open) => {
setSheetOpen(open);
if (!open) {
setEditingConfiguration(null);
}
}}
>
<SheetContent side='right' className='w-full overflow-y-auto sm:max-w-2xl'>
<SheetHeader>
<SheetTitle>{editingConfiguration ? 'Edit SMTP configuration' : 'Add SMTP configuration'}</SheetTitle>
<SheetDescription>
Keep the profile readable for operators. Passwords can be updated, but never viewed again.
</SheetDescription>
</SheetHeader>
<div className='mt-6'>
<form.AppForm>
<form.Form className='space-y-4' id='email-configuration-form'>
<div className='grid gap-4 md:grid-cols-2'>
<FormTextField
name='name'
label='Configuration name'
placeholder='Primary corporate SMTP'
required
/>
<FormTextField
name='host'
label='SMTP host'
placeholder='smtp.example.com'
required
/>
<FormTextField
name='port'
label='Port'
placeholder='587'
required
/>
<FormTextField
name='username'
label='Username'
placeholder='mailer@example.com'
/>
<FormTextField
name='password'
label={editingConfiguration?.hasPassword ? 'Replace password' : 'Password'}
type='password'
placeholder={editingConfiguration?.hasPassword ? 'Leave blank to keep existing password' : 'SMTP password'}
/>
<FormTextField
name='fromEmail'
label='From email'
type='email'
placeholder='noreply@example.com'
required
/>
<FormTextField
name='fromName'
label='From name'
placeholder='ALLA OS'
/>
<FormTextField
name='replyToEmail'
label='Reply-to email'
type='email'
placeholder='support@example.com'
/>
</div>
<div className='grid gap-3 rounded-2xl border border-border/70 bg-muted/25 p-4'>
<FormSwitchField
name='secure'
label='Use secure SMTP transport'
description='Enable this when the provider expects implicit TLS, usually on port 465.'
/>
<FormSwitchField
name='isDefault'
label='Make this the default delivery route'
description='The default profile is used by email notifications when no override is selected.'
/>
<FormSwitchField
name='isActive'
label='Allow this profile for runtime delivery'
description='Inactive profiles stay visible for audit and editing, but are skipped for sending.'
/>
{editingConfiguration?.hasPassword ? (
<FormSwitchField
name='clearPassword'
label='Clear stored password on save'
description='Use this only when the SMTP server no longer requires the stored credential.'
/>
) : null}
</div>
</form.Form>
</form.AppForm>
</div>
<SheetFooter className='mt-6'>
<Button
type='button'
variant='outline'
onClick={() => {
setSheetOpen(false);
setEditingConfiguration(null);
}}
disabled={saving}
>
Cancel
</Button>
<Button type='submit' form='email-configuration-form' isLoading={saving}>
<Icons.check className={cn('mr-2 h-4 w-4', saving && 'hidden')} />
{editingConfiguration ? 'Save changes' : 'Create configuration'}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</div>
);
}

View File

@@ -0,0 +1,88 @@
'use client';
import Link from 'next/link';
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Icons } from '@/components/icons';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { emailConfigurationsQueryOptions } from '../api/queries';
export function SetupEmailCard() {
const query = useQuery(emailConfigurationsQueryOptions());
const summary = useMemo(() => {
const configurations = query.data ?? [];
const defaultConfiguration = configurations.find((item) => item.isDefault) ?? null;
const passingCount = configurations.filter((item) => item.lastTestStatus === 'passed').length;
return {
total: configurations.length,
defaultName: defaultConfiguration?.name ?? 'Not configured',
passingCount,
};
}, [query.data]);
return (
<Card>
<CardHeader>
<div className='flex flex-wrap items-start justify-between gap-3'>
<div>
<CardTitle>Email Delivery</CardTitle>
<CardDescription>
Optional setup step for SMTP readiness. This does not block setup unless your chosen profile requires it.
</CardDescription>
</div>
<Badge variant='outline'>Optional</Badge>
</div>
</CardHeader>
<CardContent className='space-y-4'>
{query.isLoading ? (
<div className='flex items-center gap-2 text-sm text-muted-foreground'>
<Icons.spinner className='h-4 w-4 animate-spin' />
Loading email configuration status...
</div>
) : null}
{query.isError ? (
<Alert variant='destructive'>
<Icons.alertCircle className='h-4 w-4' />
<AlertTitle>Unable to load email status</AlertTitle>
<AlertDescription>
{query.error instanceof Error ? query.error.message : 'Unknown email configuration error.'}
</AlertDescription>
</Alert>
) : null}
{!query.isLoading && !query.isError ? (
<>
<div className='grid gap-2 sm:grid-cols-3'>
<div className='rounded-md border p-3'>
<div className='text-xs font-medium uppercase text-muted-foreground'>Profiles</div>
<div className='text-lg font-semibold'>{summary.total}</div>
</div>
<div className='rounded-md border p-3'>
<div className='text-xs font-medium uppercase text-muted-foreground'>Ready</div>
<div className='text-lg font-semibold'>{summary.passingCount}</div>
</div>
<div className='rounded-md border p-3'>
<div className='text-xs font-medium uppercase text-muted-foreground'>Default</div>
<div className='truncate text-sm font-semibold'>{summary.defaultName}</div>
</div>
</div>
<div className='rounded-md border border-dashed p-4 text-sm text-muted-foreground'>
Configure SMTP now if you want setup verification to include live email readiness against the active organization.
</div>
</>
) : null}
<Button asChild type='button' variant='outline'>
<Link href='/dashboard/administration/email'>Open email configuration</Link>
</Button>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,420 @@
import 'server-only';
import { and, desc, eq, ne } from 'drizzle-orm';
import { emailConfigurations } from '@/db/schema';
import { auditAction, auditCreate, auditDelete, auditUpdate } from '@/features/foundation/audit-log/service';
import { decryptSecret, encryptSecret } from '@/features/foundation/secrets/server/crypto';
import { db } from '@/lib/db';
import type { EmailProviderConfig } from './email-provider';
export type EmailConfigurationProviderType = 'smtp';
export type EmailConfigurationTestStatus = 'not_tested' | 'passed' | 'failed';
export type EmailConfigurationRecord = {
id: string;
organizationId: string;
providerType: EmailConfigurationProviderType;
name: string;
host: string;
port: number;
secure: boolean;
username: string | null;
fromEmail: string;
fromName: string | null;
replyToEmail: string | null;
isDefault: boolean;
isActive: boolean;
hasPassword: boolean;
lastTestStatus: EmailConfigurationTestStatus;
lastTestedAt: string | null;
lastTestError: string | null;
createdBy: string;
updatedBy: string;
createdAt: string;
updatedAt: string;
};
export type SaveEmailConfigurationInput = {
providerType: EmailConfigurationProviderType;
name: string;
host: string;
port: number;
secure: boolean;
username?: string | null;
password?: string | null;
clearPassword?: boolean;
fromEmail: string;
fromName?: string | null;
replyToEmail?: string | null;
isDefault?: boolean;
isActive?: boolean;
};
type EmailConfigurationRow = typeof emailConfigurations.$inferSelect;
function toIso(value: Date | null): string | null {
return value ? value.toISOString() : null;
}
function mapRecord(row: EmailConfigurationRow): EmailConfigurationRecord {
return {
id: row.id,
organizationId: row.organizationId,
providerType: row.providerType as EmailConfigurationProviderType,
name: row.name,
host: row.host,
port: row.port,
secure: row.secure,
username: row.username ?? null,
fromEmail: row.fromEmail,
fromName: row.fromName ?? null,
replyToEmail: row.replyToEmail ?? null,
isDefault: row.isDefault,
isActive: row.isActive,
hasPassword: Boolean(row.passwordEncrypted),
lastTestStatus: row.lastTestStatus as EmailConfigurationTestStatus,
lastTestedAt: toIso(row.lastTestedAt),
lastTestError: row.lastTestError ?? null,
createdBy: row.createdBy,
updatedBy: row.updatedBy,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
function normalizeOptionalText(value?: string | null): string | null {
const nextValue = value?.trim() ?? '';
return nextValue.length > 0 ? nextValue : null;
}
function truncateErrorMessage(value: string): string {
return value.slice(0, 500);
}
async function unsetOtherDefaults(organizationId: string, currentId?: string): Promise<void> {
await db
.update(emailConfigurations)
.set({
isDefault: false,
updatedAt: new Date(),
})
.where(
and(
eq(emailConfigurations.organizationId, organizationId),
currentId ? ne(emailConfigurations.id, currentId) : undefined
)
);
}
export async function listEmailConfigurations(organizationId: string): Promise<EmailConfigurationRecord[]> {
const rows = await db
.select()
.from(emailConfigurations)
.where(eq(emailConfigurations.organizationId, organizationId))
.orderBy(desc(emailConfigurations.isDefault), desc(emailConfigurations.isActive), emailConfigurations.name);
return rows.map(mapRecord);
}
export async function getEmailConfiguration(
organizationId: string,
id: string
): Promise<EmailConfigurationRecord | null> {
const [row] = await db
.select()
.from(emailConfigurations)
.where(and(eq(emailConfigurations.organizationId, organizationId), eq(emailConfigurations.id, id)))
.limit(1);
return row ? mapRecord(row) : null;
}
export async function getEmailConfigurationForUse(
organizationId: string,
providerType: EmailConfigurationProviderType = 'smtp'
): Promise<(EmailProviderConfig & { id: string; name: string }) | null> {
const [row] = await db
.select()
.from(emailConfigurations)
.where(
and(
eq(emailConfigurations.organizationId, organizationId),
eq(emailConfigurations.providerType, providerType),
eq(emailConfigurations.isActive, true)
)
)
.orderBy(desc(emailConfigurations.isDefault), desc(emailConfigurations.updatedAt))
.limit(1);
if (!row) {
return null;
}
return {
id: row.id,
name: row.name,
providerType: row.providerType as EmailConfigurationProviderType,
host: row.host,
port: row.port,
secure: row.secure,
username: row.username ?? null,
password: row.passwordEncrypted ? decryptSecret(row.passwordEncrypted) : null,
fromEmail: row.fromEmail,
fromName: row.fromName ?? null,
replyToEmail: row.replyToEmail ?? null,
};
}
export async function getEmailConfigurationByIdForUse(
organizationId: string,
id: string
): Promise<(EmailProviderConfig & { id: string; name: string }) | null> {
const [row] = await db
.select()
.from(emailConfigurations)
.where(and(eq(emailConfigurations.organizationId, organizationId), eq(emailConfigurations.id, id)))
.limit(1);
if (!row) {
return null;
}
return {
id: row.id,
name: row.name,
providerType: row.providerType as EmailConfigurationProviderType,
host: row.host,
port: row.port,
secure: row.secure,
username: row.username ?? null,
password: row.passwordEncrypted ? decryptSecret(row.passwordEncrypted) : null,
fromEmail: row.fromEmail,
fromName: row.fromName ?? null,
replyToEmail: row.replyToEmail ?? null,
};
}
export async function createEmailConfiguration(input: {
organizationId: string;
userId: string;
values: SaveEmailConfigurationInput;
}): Promise<EmailConfigurationRecord> {
const now = new Date();
const username = normalizeOptionalText(input.values.username);
const shouldEncryptPassword = typeof input.values.password === 'string' && input.values.password.trim().length > 0;
if (input.values.isDefault) {
await unsetOtherDefaults(input.organizationId);
}
const [created] = await db
.insert(emailConfigurations)
.values({
id: crypto.randomUUID(),
organizationId: input.organizationId,
providerType: input.values.providerType,
name: input.values.name.trim(),
host: input.values.host.trim(),
port: input.values.port,
secure: input.values.secure,
username,
passwordEncrypted: shouldEncryptPassword ? encryptSecret(input.values.password!.trim()) : null,
fromEmail: input.values.fromEmail.trim().toLowerCase(),
fromName: normalizeOptionalText(input.values.fromName),
replyToEmail: normalizeOptionalText(input.values.replyToEmail)?.toLowerCase() ?? null,
isDefault: Boolean(input.values.isDefault),
isActive: input.values.isActive ?? true,
lastTestStatus: 'not_tested',
createdBy: input.userId,
updatedBy: input.userId,
createdAt: now,
updatedAt: now,
})
.returning();
await auditCreate({
organizationId: input.organizationId,
userId: input.userId,
entityType: 'email_configuration',
entityId: created.id,
afterData: {
providerType: created.providerType,
name: created.name,
host: created.host,
port: created.port,
secure: created.secure,
username: created.username,
fromEmail: created.fromEmail,
isDefault: created.isDefault,
isActive: created.isActive,
hasPassword: Boolean(created.passwordEncrypted),
},
});
return mapRecord(created);
}
export async function updateEmailConfiguration(input: {
organizationId: string;
userId: string;
id: string;
values: SaveEmailConfigurationInput;
}): Promise<EmailConfigurationRecord> {
const [existing] = await db
.select()
.from(emailConfigurations)
.where(and(eq(emailConfigurations.organizationId, input.organizationId), eq(emailConfigurations.id, input.id)))
.limit(1);
if (!existing) {
throw new Error('Email configuration was not found.');
}
if (input.values.isDefault) {
await unsetOtherDefaults(input.organizationId, input.id);
}
const passwordEncrypted =
input.values.clearPassword === true
? null
: typeof input.values.password === 'string' && input.values.password.trim().length > 0
? encryptSecret(input.values.password.trim())
: existing.passwordEncrypted;
const [updated] = await db
.update(emailConfigurations)
.set({
providerType: input.values.providerType,
name: input.values.name.trim(),
host: input.values.host.trim(),
port: input.values.port,
secure: input.values.secure,
username: normalizeOptionalText(input.values.username),
passwordEncrypted,
fromEmail: input.values.fromEmail.trim().toLowerCase(),
fromName: normalizeOptionalText(input.values.fromName),
replyToEmail: normalizeOptionalText(input.values.replyToEmail)?.toLowerCase() ?? null,
isDefault: Boolean(input.values.isDefault),
isActive: input.values.isActive ?? existing.isActive,
updatedBy: input.userId,
updatedAt: new Date(),
})
.where(and(eq(emailConfigurations.organizationId, input.organizationId), eq(emailConfigurations.id, input.id)))
.returning();
await auditUpdate({
organizationId: input.organizationId,
userId: input.userId,
entityType: 'email_configuration',
entityId: updated.id,
beforeData: {
...mapRecord(existing),
hasPassword: Boolean(existing.passwordEncrypted),
},
afterData: mapRecord(updated),
});
return mapRecord(updated);
}
export async function deleteEmailConfiguration(input: {
organizationId: string;
userId: string;
id: string;
}): Promise<void> {
const [existing] = await db
.select()
.from(emailConfigurations)
.where(and(eq(emailConfigurations.organizationId, input.organizationId), eq(emailConfigurations.id, input.id)))
.limit(1);
if (!existing) {
throw new Error('Email configuration was not found.');
}
await db
.delete(emailConfigurations)
.where(and(eq(emailConfigurations.organizationId, input.organizationId), eq(emailConfigurations.id, input.id)));
if (existing.isDefault) {
const [fallback] = await db
.select()
.from(emailConfigurations)
.where(eq(emailConfigurations.organizationId, input.organizationId))
.orderBy(desc(emailConfigurations.isActive), desc(emailConfigurations.updatedAt))
.limit(1);
if (fallback) {
await db
.update(emailConfigurations)
.set({
isDefault: true,
updatedBy: input.userId,
updatedAt: new Date(),
})
.where(eq(emailConfigurations.id, fallback.id));
}
}
await auditDelete({
organizationId: input.organizationId,
userId: input.userId,
entityType: 'email_configuration',
entityId: input.id,
beforeData: mapRecord(existing),
});
}
export async function markEmailConfigurationTestResult(input: {
organizationId: string;
userId: string;
id: string;
status: Exclude<EmailConfigurationTestStatus, 'not_tested'>;
errorMessage?: string | null;
}): Promise<EmailConfigurationRecord> {
const [updated] = await db
.update(emailConfigurations)
.set({
lastTestStatus: input.status,
lastTestedAt: new Date(),
lastTestError: input.errorMessage ? truncateErrorMessage(input.errorMessage) : null,
updatedBy: input.userId,
updatedAt: new Date(),
})
.where(and(eq(emailConfigurations.organizationId, input.organizationId), eq(emailConfigurations.id, input.id)))
.returning();
if (!updated) {
throw new Error('Email configuration was not found.');
}
await auditAction({
organizationId: input.organizationId,
userId: input.userId,
entityType: 'email_configuration',
entityId: input.id,
action: input.status === 'passed' ? 'email_configuration_test_passed' : 'email_configuration_test_failed',
afterData: {
lastTestStatus: updated.lastTestStatus,
lastTestedAt: toIso(updated.lastTestedAt),
lastTestError: updated.lastTestError,
},
});
return mapRecord(updated);
}
export async function hasActiveEmailConfiguration(organizationId: string): Promise<boolean> {
const [row] = await db
.select({ id: emailConfigurations.id })
.from(emailConfigurations)
.where(
and(
eq(emailConfigurations.organizationId, organizationId),
eq(emailConfigurations.isActive, true)
)
)
.limit(1);
return Boolean(row);
}

View File

@@ -0,0 +1,52 @@
import 'server-only';
export type EmailProviderConfig = {
providerType: 'smtp';
host: string;
port: number;
secure: boolean;
username: string | null;
password: string | null;
fromEmail: string;
fromName: string | null;
replyToEmail: string | null;
};
export type EmailTestResult = {
ok: boolean;
detail?: string;
};
export type SendEmailRecipient = {
email: string;
name?: string | null;
};
export type SendEmailAttachment = {
fileName: string;
contentType: string;
buffer: Buffer;
};
export type SendEmailInput = {
subject: string;
text: string;
html?: string | null;
to: SendEmailRecipient[];
cc?: SendEmailRecipient[];
bcc?: SendEmailRecipient[];
replyTo?: string | null;
attachments?: SendEmailAttachment[];
};
export type SendEmailResult = {
providerMessageId: string | null;
acceptedRecipients: string[];
rejectedRecipients: string[];
metadata?: Record<string, unknown>;
};
export interface EmailProvider {
testConnection(config: EmailProviderConfig): Promise<EmailTestResult>;
sendEmail(config: EmailProviderConfig, input: SendEmailInput): Promise<SendEmailResult>;
}

View File

@@ -0,0 +1,85 @@
import 'server-only';
import type { SendEmailResult } from './email-provider';
import { SmtpEmailProvider } from './smtp-provider';
import {
getEmailConfigurationByIdForUse,
markEmailConfigurationTestResult,
} from './email-config.service';
export async function testEmailConfigurationConnection(input: {
organizationId: string;
userId: string;
configurationId: string;
}) {
const config = await getEmailConfigurationByIdForUse(input.organizationId, input.configurationId);
if (!config) {
throw new Error('Email configuration was not found.');
}
const provider = new SmtpEmailProvider();
const result = await provider.testConnection(config);
await markEmailConfigurationTestResult({
organizationId: input.organizationId,
userId: input.userId,
id: config.id,
status: result.ok ? 'passed' : 'failed',
errorMessage: result.detail ?? null,
});
return result;
}
export async function sendTestEmail(input: {
organizationId: string;
userId: string;
configurationId: string;
recipientEmail: string;
}): Promise<SendEmailResult> {
const config = await getEmailConfigurationByIdForUse(input.organizationId, input.configurationId);
if (!config) {
throw new Error('Email configuration was not found.');
}
const provider = new SmtpEmailProvider();
try {
const result = await provider.sendEmail(config, {
subject: `ALLA OS test email from ${config.name}`,
text: [
'This is a test email from ALLA OS.',
'',
`Configuration: ${config.name}`,
`SMTP host: ${config.host}:${config.port}`,
`Sent at: ${new Date().toISOString()}`,
].join('\n'),
html: [
'<p>This is a test email from <strong>ALLA OS</strong>.</p>',
`<p>Configuration: <strong>${config.name}</strong></p>`,
`<p>SMTP host: <code>${config.host}:${config.port}</code></p>`,
`<p>Sent at: <code>${new Date().toISOString()}</code></p>`,
].join(''),
to: [{ email: input.recipientEmail.trim().toLowerCase() }],
});
await markEmailConfigurationTestResult({
organizationId: input.organizationId,
userId: input.userId,
id: config.id,
status: 'passed',
});
return result;
} catch (error) {
await markEmailConfigurationTestResult({
organizationId: input.organizationId,
userId: input.userId,
id: config.id,
status: 'failed',
errorMessage: error instanceof Error ? error.message : 'Unknown email send failure',
});
throw error;
}
}

View File

@@ -0,0 +1,89 @@
import 'server-only';
import type SMTPTransport from 'nodemailer/lib/smtp-transport';
import type { Transporter } from 'nodemailer';
import type {
EmailProvider,
EmailProviderConfig,
EmailTestResult,
SendEmailInput,
SendEmailRecipient,
SendEmailResult,
} from './email-provider';
function formatRecipient(recipient: SendEmailRecipient): string {
return recipient.name?.trim()
? `"${recipient.name.trim()}" <${recipient.email}>`
: recipient.email;
}
function getFromValue(config: EmailProviderConfig): string {
return config.fromName?.trim()
? `"${config.fromName.trim()}" <${config.fromEmail}>`
: config.fromEmail;
}
async function createTransporter(config: EmailProviderConfig): Promise<Transporter> {
const nodemailer = await import('nodemailer');
return nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: config.username
? {
user: config.username,
pass: config.password ?? undefined,
}
: undefined,
} satisfies SMTPTransport.Options);
}
function normalizeRecipients(values: Array<string | { address: string }> | undefined): string[] {
return Array.isArray(values) ? values.map((value) => String(value)) : [];
}
export class SmtpEmailProvider implements EmailProvider {
async testConnection(config: EmailProviderConfig): Promise<EmailTestResult> {
const transporter = await createTransporter(config);
try {
await transporter.verify();
return { ok: true };
} catch (error) {
return {
ok: false,
detail: error instanceof Error ? error.message : 'Unknown SMTP health check failure',
};
}
}
async sendEmail(config: EmailProviderConfig, input: SendEmailInput): Promise<SendEmailResult> {
const transporter = await createTransporter(config);
const info = await transporter.sendMail({
from: getFromValue(config),
to: input.to.map(formatRecipient),
cc: input.cc?.map(formatRecipient),
bcc: input.bcc?.map(formatRecipient),
replyTo: input.replyTo ?? config.replyToEmail ?? undefined,
subject: input.subject,
text: input.text,
html: input.html ?? undefined,
attachments: input.attachments?.map((attachment) => ({
filename: attachment.fileName,
content: attachment.buffer,
contentType: attachment.contentType,
})),
});
return {
providerMessageId: info.messageId ?? null,
acceptedRecipients: normalizeRecipients(info.accepted),
rejectedRecipients: normalizeRecipients(info.rejected),
metadata: {
envelope: info.envelope,
response: info.response ?? null,
},
};
}
}

View File

@@ -1,7 +1,7 @@
import 'server-only'; import 'server-only';
import type { Transporter } from 'nodemailer'; import { getEmailConfigurationForUse } from '@/features/foundation/email/server/email-config.service';
import type SMTPTransport from 'nodemailer/lib/smtp-transport'; import { SmtpEmailProvider } from '@/features/foundation/email/server/smtp-provider';
import type { import type {
NotificationChannel, NotificationChannel,
NotificationProvider, NotificationProvider,
@@ -9,126 +9,59 @@ import type {
NotificationProviderSendResult, NotificationProviderSendResult,
} from '../types'; } from '../types';
type SmtpConfig = {
host: string;
port: number;
secure: boolean;
user?: string;
pass?: string;
from: string;
};
function parseBoolean(value: string | undefined, fallback: boolean) {
if (!value) {
return fallback;
}
return value === 'true' || value === '1';
}
function getSmtpConfig(): SmtpConfig {
const host = process.env.SMTP_HOST?.trim() ?? '';
const port = Number(process.env.SMTP_PORT ?? '587');
const secure = parseBoolean(process.env.SMTP_SECURE, port === 465);
const user = process.env.SMTP_USER?.trim();
const pass = process.env.SMTP_PASS?.trim();
const from = process.env.SMTP_FROM?.trim() ?? '';
return { host, port, secure, user, pass, from };
}
async function createTransporter() {
const nodemailer = await import('nodemailer');
const config = getSmtpConfig();
return nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: config.user ? { user: config.user, pass: config.pass } : undefined,
} satisfies SMTPTransport.Options);
}
function formatRecipients(
recipients: NotificationProviderSendInput['to'],
): string[] {
return recipients.map((recipient) =>
recipient.name?.trim()
? `"${recipient.name.trim()}" <${recipient.email}>`
: recipient.email,
);
}
export class EmailNotificationProvider implements NotificationProvider { export class EmailNotificationProvider implements NotificationProvider {
readonly channel: NotificationChannel = 'email'; readonly channel: NotificationChannel = 'email';
readonly key = 'smtp'; readonly key = 'smtp';
constructor(private readonly organizationId: string) {}
private async resolveConfig() {
const config = await getEmailConfigurationForUse(this.organizationId, 'smtp');
if (!config) {
throw new Error('No active SMTP email configuration was found for this organization.');
}
return config;
}
async validate() { async validate() {
const config = getSmtpConfig(); await this.resolveConfig();
if (!config.host) {
throw new Error('SMTP host is not configured.');
}
if (!Number.isFinite(config.port) || config.port <= 0) {
throw new Error('SMTP port is invalid.');
}
if (!config.from) {
throw new Error('SMTP from address is not configured.');
}
} }
async healthCheck() { async healthCheck() {
try { try {
await this.validate(); const config = await this.resolveConfig();
const transporter = await createTransporter(); const provider = new SmtpEmailProvider();
await transporter.verify(); const result = await provider.testConnection(config);
return { ok: true as const }; return result.ok
? { ok: true }
: { ok: false, detail: result.detail ?? 'SMTP health check failed.' };
} catch (error) { } catch (error) {
return { return {
ok: false as const, ok: false,
detail: error instanceof Error ? error.message : 'Unknown SMTP health check failure', detail: error instanceof Error ? error.message : 'Unknown SMTP health check failure',
}; };
} }
} }
async send( async send(input: NotificationProviderSendInput): Promise<NotificationProviderSendResult> {
input: NotificationProviderSendInput, const config = await this.resolveConfig();
): Promise<NotificationProviderSendResult> { const provider = new SmtpEmailProvider();
await this.validate();
const transporter = (await createTransporter()) as Transporter;
const config = getSmtpConfig();
const info = await transporter.sendMail({ return provider.sendEmail(config, {
from: config.from,
to: formatRecipients(input.to),
cc: input.cc.length ? formatRecipients(input.cc) : undefined,
bcc: input.bcc.length ? formatRecipients(input.bcc) : undefined,
replyTo: input.replyTo ?? undefined,
subject: input.subject, subject: input.subject,
text: input.bodyText, text: input.bodyText,
html: input.bodyHtml ?? undefined, html: input.bodyHtml,
to: input.to,
cc: input.cc,
bcc: input.bcc,
replyTo: input.replyTo,
attachments: input.attachments.map((attachment, index) => ({ attachments: input.attachments.map((attachment, index) => ({
filename: attachment.fileName, fileName: attachment.fileName,
content: input.attachmentBuffers[index],
contentType: attachment.contentType, contentType: attachment.contentType,
buffer: input.attachmentBuffers[index] ?? Buffer.alloc(0),
})), })),
}); });
return {
providerMessageId: info.messageId ?? null,
acceptedRecipients: Array.isArray(info.accepted)
? info.accepted.map((value: string | { address: string }) => String(value))
: [],
rejectedRecipients: Array.isArray(info.rejected)
? info.rejected.map((value: string | { address: string }) => String(value))
: [],
metadata: {
envelope: info.envelope,
response: info.response ?? null,
},
};
} }
} }

View File

@@ -5,11 +5,11 @@ import type { NotificationChannel, NotificationProvider } from '../types';
export function getNotificationProvider( export function getNotificationProvider(
channel: NotificationChannel, channel: NotificationChannel,
providerKey?: string, organizationId: string,
providerKey?: string
): NotificationProvider { ): NotificationProvider {
if (channel === 'email') { if (channel === 'email') {
const provider = new EmailNotificationProvider(); const provider = new EmailNotificationProvider(organizationId);
if (providerKey && provider.key !== providerKey) { if (providerKey && provider.key !== providerKey) {
throw new Error(`Unsupported email provider: ${providerKey}`); throw new Error(`Unsupported email provider: ${providerKey}`);
} }

View File

@@ -430,7 +430,11 @@ async function runDispatch(input: {
dispatch: NotificationDispatchRecord; dispatch: NotificationDispatchRecord;
attachments: ResolvedNotificationAttachment[]; attachments: ResolvedNotificationAttachment[];
}) { }) {
const provider = getNotificationProvider(input.dispatch.channel, input.dispatch.providerKey); const provider = getNotificationProvider(
input.dispatch.channel,
input.organizationId,
input.dispatch.providerKey
);
await db await db
.update(appNotificationDispatches) .update(appNotificationDispatches)

View File

@@ -0,0 +1,82 @@
import 'server-only';
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
const ENCRYPTION_PREFIX = 'enc:v1';
const ENCRYPTION_ENV_KEY = 'EMAIL_CONFIG_ENCRYPTION_KEY';
const MIN_KEY_BYTES = 32;
function decodeConfiguredKey(rawValue: string): Buffer {
const value = rawValue.trim();
if (/^[0-9a-fA-F]+$/.test(value) && value.length % 2 === 0) {
return Buffer.from(value, 'hex');
}
try {
const base64Buffer = Buffer.from(value, 'base64');
if (base64Buffer.length > 0 && base64Buffer.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')) {
return base64Buffer;
}
} catch {
// Fall back to UTF-8 below.
}
return Buffer.from(value, 'utf8');
}
function getEncryptionKeyMaterial(): Buffer {
const configured = process.env[ENCRYPTION_ENV_KEY]?.trim() ?? '';
if (!configured) {
throw new Error(`${ENCRYPTION_ENV_KEY} is not configured.`);
}
const keyMaterial = decodeConfiguredKey(configured);
if (keyMaterial.byteLength < MIN_KEY_BYTES) {
throw new Error(`${ENCRYPTION_ENV_KEY} must provide at least ${MIN_KEY_BYTES} bytes of key material.`);
}
return createHash('sha256').update(keyMaterial).digest();
}
export function assertSecretsEncryptionReady(): void {
getEncryptionKeyMaterial();
}
export function encryptSecret(plainText: string): string {
const normalized = plainText.trim();
if (!normalized) {
throw new Error('Secret value is required for encryption.');
}
const key = getEncryptionKeyMaterial();
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key, iv);
const encrypted = Buffer.concat([cipher.update(normalized, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return [
ENCRYPTION_PREFIX,
iv.toString('base64'),
tag.toString('base64'),
encrypted.toString('base64'),
].join(':');
}
export function decryptSecret(cipherText: string): string {
const [prefix, ivPart, tagPart, encryptedPart] = cipherText.split(':');
if (prefix !== ENCRYPTION_PREFIX || !ivPart || !tagPart || !encryptedPart) {
throw new Error('Encrypted secret format is invalid.');
}
const key = getEncryptionKeyMaterial();
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(ivPart, 'base64'));
decipher.setAuthTag(Buffer.from(tagPart, 'base64'));
const plain = Buffer.concat([
decipher.update(Buffer.from(encryptedPart, 'base64')),
decipher.final(),
]);
return plain.toString('utf8');
}

View File

@@ -11,6 +11,7 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { SetupEmailCard } from '@/features/foundation/email/components/setup-email-card';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { import {
useConfiguredBootstrapCsvCommit, useConfiguredBootstrapCsvCommit,
@@ -616,16 +617,17 @@ export function SetupWizard({ mode = 'admin', tokenRequired = false }: SetupWiza
onSelect={setCurrentStepKey} onSelect={setCurrentStepKey}
/> />
<div className='space-y-6'> <div className='space-y-6'>
<SourcePanel source={importSource} /> <SourcePanel source={importSource} />
<SelectedFileDetail <SelectedFileDetail
step={currentStep} step={currentStep}
sourceFile={selectedSourceFile} sourceFile={selectedSourceFile}
template={selectedTemplate} template={selectedTemplate}
previewFile={selectedPreviewFile} previewFile={selectedPreviewFile}
/> />
<SetupEmailCard />
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Final Verification</CardTitle> <CardTitle>Final Verification</CardTitle>
<CardDescription>Setup can complete after system-required import steps pass.</CardDescription> <CardDescription>Setup can complete after system-required import steps pass.</CardDescription>

View File

@@ -15,7 +15,8 @@ import {
organizations, organizations,
users users
} from '@/db/schema'; } from '@/db/schema';
import { EmailNotificationProvider } from '@/features/foundation/notifications/server/email-provider'; import { getEmailConfigurationForUse } from '@/features/foundation/email/server/email-config.service';
import { SmtpEmailProvider } from '@/features/foundation/email/server/smtp-provider';
import { getStorageProvider } from '@/features/foundation/storage/service'; import { getStorageProvider } from '@/features/foundation/storage/service';
import { getSetupPluginRegistry } from '@/features/setup/plugins/plugin-registry'; import { getSetupPluginRegistry } from '@/features/setup/plugins/plugin-registry';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
@@ -366,28 +367,44 @@ export async function checkPdfMappingCoverage(
}); });
} }
export async function checkEmailConfig(): Promise<SetupCheckResult> { export async function checkEmailConfig(organizationId?: string | null): Promise<SetupCheckResult> {
const meta: CheckMeta = { const meta: CheckMeta = {
id: 'EMAIL_CONFIG', id: 'EMAIL_CONFIG',
area: 'Email', area: 'Email',
name: 'Email configuration', name: 'Email configuration',
requiredFor: ['optional notifications'], requiredFor: ['optional notifications'],
purpose: 'Confirm optional email readiness.', purpose: 'Confirm optional email readiness.',
suggestedFix: 'Fix SMTP provider settings.' suggestedFix: 'Fix SMTP provider settings.'
}; };
return runSafeCheck(meta, async () => { return runSafeCheck(meta, async () => {
const hasSmtpConfig = Boolean(process.env.SMTP_HOST?.trim() || process.env.SMTP_FROM?.trim()); if (!organizationId) {
if (!hasSmtpConfig) { return buildSetupCheck(
return buildSetupCheck( meta,
meta, 'WARNING',
'WARNING', 'No organization context available for email verification yet.'
'SMTP email is not configured; in-app notifications can still work.' );
); }
} const config = await getEmailConfigurationForUse(organizationId, 'smtp');
const provider = new EmailNotificationProvider(); if (!config) {
await provider.validate(); return buildSetupCheck(
return buildSetupCheck(meta, 'PASS', 'SMTP email configuration is valid.'); meta,
}); 'WARNING',
'Email delivery is not configured for this organization; in-app notifications can still work.'
);
}
const provider = new SmtpEmailProvider();
const result = await provider.testConnection(config);
if (!result.ok) {
return buildSetupCheck(
meta,
'FAIL',
result.detail
? `Email configuration exists but SMTP connection failed: ${result.detail}`
: 'Email configuration exists but SMTP connection failed.'
);
}
return buildSetupCheck(meta, 'PASS', 'Organization email configuration is valid.');
});
} }
export async function getSetupReadinessReport(input: { export async function getSetupReadinessReport(input: {
@@ -419,7 +436,7 @@ export async function getSetupReadinessReport(input: {
checkUploadDirectory(), checkUploadDirectory(),
checkPdfTemplateAsset(input.organizationId), checkPdfTemplateAsset(input.organizationId),
checkPdfMappingCoverage(input.organizationId), checkPdfMappingCoverage(input.organizationId),
checkEmailConfig() checkEmailConfig(input.organizationId)
]); ]);
return buildSetupReport([...checks, ...pluginChecks], 'Setup readiness checks completed.'); return buildSetupReport([...checks, ...pluginChecks], 'Setup readiness checks completed.');

View File

@@ -422,7 +422,7 @@ export async function getSetupVerificationReport(
) )
); );
checks.push(await checkEmailConfig()); checks.push(await checkEmailConfig(organizationId));
checks.push( checks.push(
buildSetupCheck( buildSetupCheck(