First-party Paca plugin that sends transactional email over an admin-configured SMTP server: new-account invites, password-reset links, and task-assignment/@mention notifications. It's the first of what's expected to be a family of mail plugins (SES, SendGrid, Mailgun, …) — the app core has no built-in notion of "email" at all; it only publishes generic domain events to a plugin event stream, and this plugin is the one that happens to turn some of them into email.
- Admin settings page (
Email (SMTP), under Admin) — host/port/username/ password/from-address form, a live "send test email" button, and a per-event toggle grid for which optional notification types get emailed. Connection settings save via an explicit button; event toggles save immediately on change. - Per-user preferences tab (
Email Notifications, on the profile page) — lets each user opt out of whichever optional event types the admin has enabled, for their own account. - Two mandatory emails, sent regardless of the admin's event toggles,
whenever SMTP is configured at all:
- Welcome / set-your-password, when an account is created with an email on file.
- Password reset, when an admin resets a user's password.
- Four optional, togglable/opt-outable emails: assigned to a task, mentioned in a comment, mentioned in a document, mentioned in a task description. All four default to enabled.
- Every email is rendered as an HTML+plain-text pair matching the web app's light-mode design tokens (fonts, colors, spacing), and reflects the workspace's custom brand name/logo/primary color live at send time if the admin has set them — never a value baked in earlier.
smtp/
├── backend/ — Go WASM plugin (runs inside the API host)
└── frontend/ — React micro-frontend (Module Federation remote)
- Written in Go, compiled to
wasip1/wasmfor production (main.gocarries a//go:build wasip1tag, so a plaingo build ./...withoutGOOS=wasip1 GOARCH=wasmfails with "function main is undeclared in the main package" — expected, not a real error;go vet/go testwork fine without the cross-compile target). - Registered as
com.paca.smtpin the plugin registry. - Owns its own schema (
plugin_data_com_paca_smtp): a singletonsmtp_configrow (server settings + which optional events are enabled) and auser_email_preferencesrow per user who has opted out of anything (seebackend/migrations/0001_create_smtp_tables.sql). smtp_config.password_encis AES-256-GCM encrypted at rest (backend/crypto.go, keyed by theENCRYPTION_KEYhost config value) and declared inplugin.json'ssensitiveFieldsso other plugins can't read it back out viadb_queryagainst this plugin's schema.- Event-driven (
backend/events.go,handleEvent): subscribes to six topics on the host's plugin event stream (ctx.On(topic, ...)) and, for each one that fires, checks whether SMTP is actually configured, whether the topic is enabled (mandatory topics skip this check), and — for optional topics — whether the recipient has personally opted out, before rendering and sending. - Two outbound-only host capabilities this plugin declares permission for
and calls through private
go:wasmimportbindings (backend/mail_wasm.go,backend/password_token_wasm.go) — see "Host permissions" below.
- Vite + React + TanStack Query, Module Federation remote exposing two entry
points declared in
plugin.json:./AdminSmtpSettingsPage—admin.pageextension point, reached through an admin-sidebar nav item this plugin registers../EmailPreferencesTab—user.settings.tabextension point, mounted as an additional card on the host's own profile page.
smtp-api.ts— thin typed wrapper aroundPluginApiClientplusOPTIONAL_EVENT_OPTIONS, the four togglable topics' labels/descriptions shown in both the admin grid and the user preferences tab.
| Topic | Mandatory? | Payload |
|---|---|---|
user.created |
yes | user_id, username, full_name, email? |
user.password_reset |
yes | user_id, username, full_name, email? |
notification.assigned |
no (default on) | recipient_user_id, recipient_email?, recipient_name, actor_name, link_url |
notification.mentioned |
no (default on) | same shape as notification.assigned |
notification.doc_mentioned |
no (default on) | same shape as notification.assigned |
notification.task_description_mentioned |
no (default on) | same shape as notification.assigned |
None of these payloads ever carry a password or a password-set token — see
"Host permissions" below for how this plugin actually gets one. A
recipient_email/email field that's empty in the payload means the
account has no email on file; this plugin's handleEvent treats that as
"nothing to do" and no-ops rather than erroring.
Declared in plugin.json's permissions array — each one gates a specific
host function this plugin calls:
email.send— dials the admin-configured SMTP server directly, unsandboxed, on this plugin's behalf (real SMTP needs a raw TCP/TLS socket, which WASI doesn't expose to guests). Seeservices/api/internal/platform/plugin/email.go.users.password_set_token.issue— mints a single-use password-set token scoped to oneuser_id. The core app deliberately never embeds this token inuser.created/user.password_reset's event payload — a password-set token is a bearer credential, and the core has no idea which (if any) subscriber actually needs one, so it isn't handed to every plugin that merely subscribes to the topic. Instead, once this plugin has already decided (SMTP configured, mandatory topic) that it's actually going to deliver a link, it calls back into the host on demand (backend/password_token_wasm.go) and gets a token minted just for that request. Seeservices/api/internal/platform/plugin/password_token.go. The resulting link is only redeemable while the account is in the must-change-password state (i.e. it hasn't already set a real password by some other means) — seeusersvc.Service.SetPasswordWithToken.
db.read/db.write (this plugin's own schema) and events.subscribe are
the two other declared permissions, needed for the config/preferences
tables and the event topics above respectively.
All routes are relative to /api/v1/plugins/com.paca.smtp and require an
authenticated, fresh-password session (optionalAuthn + requireFreshPassword).
| Method | Path | Permission | Description |
|---|---|---|---|
GET |
/admin/config |
global users.write |
Current SMTP settings (password never round-tripped) |
PATCH |
/admin/config |
global users.write |
Update settings; blank password keeps the existing one |
POST |
/admin/test-email |
global users.write |
Send a sample email to {"to": "..."} using the saved config |
GET |
/me/preferences |
global users.read |
The caller's available (admin-enabled) events and which they've disabled |
PATCH |
/me/preferences |
global users.read |
Update {"disabled_events": [...]} for the caller |
Tables live in the plugin_data_com_paca_smtp schema, created by
backend/migrations/0001_create_smtp_tables.sql:
smtp_config (singleton row, id = TRUE)
host TEXT
port INT DEFAULT 587
username TEXT
password_enc TEXT -- AES-256-GCM, hex(nonce || ciphertext+tag)
from_address TEXT
from_name TEXT
use_tls BOOLEAN DEFAULT TRUE
enabled_events JSONB -- optional topics the admin has enabled
updated_at TIMESTAMPTZ
user_email_preferences
user_id UUID PRIMARY KEY
disabled_events JSONB DEFAULT '[]'
updated_at TIMESTAMPTZ
cd backend
# Vet / test (native — no wasip1 target needed for these)
go vet ./...
go test ./...
# Build the WASM binary
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o backend.wasm .Requires ENCRYPTION_KEY (a 32-byte hex string, same value the host also
uses elsewhere) and PUBLIC_URL allowlisted via plugin.json's
allowedConfigKeys — both must be set on the host for this plugin to
encrypt the stored password and build set-password links.
cd frontend
npm install
npm run typecheck
npm run build # outputs remoteEntry.jsUses @paca-ai/plugin-sdk-react. Shared singletons (react, react-dom,
@tanstack/react-query) are provided by the host shell and must not be
bundled.
Routed via the Email (SMTP) admin-sidebar nav item declared in
plugin.json. Two independent sections: SMTP server settings (explicit Save
button, disabled while the form is unchanged) and notification events
(each toggle saves immediately).
Mounted as an additional card on the host's own profile page via the
user.settings.tab extension point. Lists whichever optional events the
admin has enabled; hidden entirely (with an explanatory message) if the
admin hasn't enabled any.