Routes, models and jobs
The server-side half of a plugin is plain RapidREST code. Anything you can write in a RapidREST service you can export from a plugin entry point, and the server treats it like its own: the class loader registers it, dependency injection fills it in, and the server mounts routes, connects models and starts jobs. This page covers what is specific to RapidMX on top of that: running on two database backends, respecting who may see whose mail, and the hooks the server gives plugins.
For the framework itself, see Defining routes, CRUD routes, Entities and columns and Background jobs on rapidrest.dev.
The entry point rule
The server imports ./mongo or ./sql and registers every export. That has two consequences:
- Export only concrete, ready classes. An abstract
BackgroundServiceexported from an entry point would be started; an abstract route would be mounted. Keep base classes, helpers and types in the package root (src/index.ts) or in modules the entry point imports but doesn't re-export. - Export each class once, from the entry point for its backend.
@rapidmx/booking-plugin's./mongoexportsBookingTypeMongo,BookingMongo,BookingProfileMongoand the three Mongo routes; its./sqlexports the SQL twins.
A cheap test catches mistakes early. The published plugins have a test/plugin.test.ts that imports both entry points,
checks that every export is what it should be, and runs parsePluginManifest() from @rapidmx/restapi over the
package's own package.json:
import "reflect-metadata";
import fs from "fs";
import { parsePluginManifest } from "@rapidmx/restapi";
import * as MongoEntry from "../src/mongo.js";
it("./mongo exports only the mounted route", () => {
const paths = Object.fromEntries(
Object.entries(MongoEntry).map(([name, clazz]) => [name, Reflect.getMetadata("rrst:routePaths", (clazz as any).prototype)]),
);
expect(paths).toEqual({ AutodiscoverRouteMongo: ["/autodiscover"] });
});
it("declares a valid plugin manifest", () => {
const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
expect(parsePluginManifest(pkg)).toEqual(expect.objectContaining({ displayName: "Autodiscover" }));
});
parsePluginManifest() is the same check the server runs, so a manifest that passes it is one the server accepts.
Routes
Paths
@ApiRoute("mail/booking-types") serves a route under /api/mail/booking-types. @Route("/autodiscover") serves it at
exactly that path, with no /api prefix; protocol plugins use it for paths that clients expect
(/Microsoft-Server-ActiveSync, /mapi/emsmdb, /autodiscover).
The server checks a plugin's page mounts against every registered route, but it does not check API routes against each
other. Choose a prefix of your own that no core route and no other plugin uses. The published plugins put theirs under
/api/mail/, named after the plugin (/api/mail/booking-types, /api/mail/crm, /api/mail/video-meetings).
Authentication
Every request reaches a route with the caller's JWT already verified. Take the user with @User (usually imported as
AuthUser) and treat undefined as anonymous:
@Get()
public async get(@AuthUser user?: JWTUser): Promise<Settings> {
if (!user) {
throw new ApiError(ApiErrors.AUTH_PERMISSION_FAILURE, 403, ApiErrorMessages.AUTH_PERMISSION_FAILURE);
}
// ...
}
A route meant for anonymous callers (a public booking page's slots, a CRM signup form) simply doesn't require a user, and
should carry @RateLimit(). A route for administrators uses @RequiresTrustedRole(), and for anything sensitive also
assertAdminScope(user, trustedRoles, maxAgeSeconds) from @rapidmx/restapi, which additionally requires a recently
elevated sign-in. The plugin management routes themselves work that way. See Authentication
for what "elevated" means.
Mailbox data and administrators
RapidMX has a rule that the framework doesn't: an administrator has no access to other people's mail. In RapidREST,
a trusted role (admin) passes every ACL check. In RapidMX, anything scoped to a mailbox must be checked as if the
caller had no trusted role at all, so an administrator sees only their own mailboxes and the ones shared with them.
So never call aclUtils.hasPermission() directly for mailbox-scoped data. Use one of these from @rapidmx/restapi:
hasMailAccess(aclUtils, trustedRoles, user, uid, action)andassertMailAccess(...), which check the caller's permission on a mailbox or folder uid with trusted roles stripped;BaseScopedChildRoute<T>, a CRUD route for records that belong to a mailbox. SetscopeProperty(usually"mailboxUid") and it checks every read and write against that mailbox, refuses to let a list query widen the scope, mints uids on the server and answers reads that are denied as if nothing matched. The booking plugin'sBaseBookingTypeRouteis built on it.
resolveCallerMailboxUid(mailboxRepo, user) finds the signed-in user's own mailbox, which is what the protocol plugins
use.
Sending mail
A plugin that sends mail should go through the same path as the server, so the message is scanned, gets its headers and
is relayed by the configured transport. ActiveSync and MAPI use scanAndRelay() from @rapidmx/restapi for this. See
Mail flow for what happens after that.
Live updates
NotificationUtils.sendMessage(channelUid, type, action, data) from @rapidrest/service-core publishes a message to
the clients subscribed to a channel over the /push WebSocket. Channels are entity uids (a mailbox, a folder, a user). See
REST API for how clients subscribe.
Models
Twin classes
Every stored type is a pair of classes, one per backend, implementing a shared interface. From the booking plugin:
// src/models/mongo/BookingTypeMongo.ts
@DataStore("mongo")
@Entity()
@MailboxScopedData()
@Index("bookingtype_mailbox_slug", ["mailboxUid", "slug"], { unique: true })
@Protect({ uid: "BookingType", records: [{ userOrRoleId: "anonymous", actions: [] }, { userOrRoleId: ".*", actions: [] }] }, false)
export class BookingTypeMongo extends BaseMongoEntity implements BookingType {
@Column()
public mailboxUid: string = "";
// ...
}
// src/models/sql/BookingTypeSQL.ts
@DataStore("sql")
@Entity()
@MailboxScopedData()
@Index("bookingtype_mailbox_slug", ["mailboxUid", "slug"], { unique: true })
@Protect({ uid: "BookingType", records: [{ userOrRoleId: "anonymous", actions: [] }, { userOrRoleId: ".*", actions: [] }] }, false)
export class BookingTypeSQL extends BaseEntity implements BookingType {
@Column()
public mailboxUid: string = "";
// ...
}
The @DataStore name is the server's datastore: mongo for the Mongo class, sql for the SQL one. The table or
collection is named after the class in snake case (booking_type_mongo, booking_type_sql), so renaming a model
class orphans the data stored under the old name. Pick names with your plugin's prefix, and don't reuse a name a core
model or another plugin uses: the server refuses to delete anything it doesn't own when a plugin's data is purged, and a
shared name makes that step fail.
The routes come in twins too. An abstract BaseBookingTypeRoute<T> in the package root holds the logic, and each
backend's concrete class only binds the model:
@ApiRoute("mail/booking-types")
@Model(BookingTypeMongo)
export class BookingTypeRouteMongo extends BaseBookingTypeRoute<BookingTypeMongo> {
protected readonly repoUtilsClass: any = RepoUtils;
protected folderClass: any = FolderMongo;
protected bookingClass: any = BookingMongo;
}
Core models a plugin needs (MailboxMongo, FolderMongo and their SQL twins) come from @rapidmx/restapi/mongo and
@rapidmx/restapi/sql.
Schema changes
RapidMX has no migrations. On startup the server creates and updates collections, tables and columns from the models
(datastores.*.synchronize). Adding a field or a model is therefore just a release, but on PostgreSQL a column whose
type changes may be dropped and recreated, losing its data. Treat a column's type as fixed once released, and add a new
column instead.
Data that belongs to a mailbox
When a model holds a mailbox's data in a mailboxUid field, mark the class @MailboxScopedData() (from
@rapidmx/restapi) and set "mailboxScopedData": true in the manifest. The decorator lets a data-subject erasure delete
the plugin's rows along with the rest of the mailbox. The manifest flag tells the server the plugin holds such data, so
an erasure waits while the plugin is installed but not loaded, rather than leaving its rows behind.
Background jobs
A job is a concrete BackgroundService subclass exported from the entry point. It returns a cron expression (with
seconds) from schedule and does its work in run(). The CRM plugin's task reminder job, trimmed:
export abstract class TaskReminderJob extends CrmJobBase {
@Config("mail:crm:jobs:reminders:schedule", "15 * * * * *")
protected scheduleExpr: string = "15 * * * * *";
public get schedule(): string | undefined {
return this.scheduleExpr;
}
public async run(): Promise<void> {
// find due tasks and notify their assignees
}
}
// src/jobs/mongo/TaskReminderJobMongo.ts, exported from ./mongo
export class TaskReminderJobMongo extends TaskReminderJob {
protected classes: CrmModelClasses = MONGO_MODELS;
}
The abstract class stays out of the entry points; only TaskReminderJobMongo and TaskReminderJobSQL are exported. Put a
job's configuration keys under the plugin's own namespace (mail:crm:jobs:*), never under the server's
mail:jobs:* keys, which plugins may not set.
A deployment can run several server copies, and each one starts every job. Write jobs so that two copies running the same
pass at once is harmless: claim work with an optimistic update (the CRM job above updates each task with its version
and skips it when another copy got there first).
Reacting to mail
To act on mail as it is delivered or sent, subclass MailEventConsumer from @rapidmx/restapi. It reads a Redis stream
the server appends to (message.delivered, message.sent and send.failed events) as one consumer group, so each event
is handled by one server copy. Set consumerGroup, optionally narrow eventTypes, and implement
handle(event, id). Events are delivered at least once, so the handler must be idempotent. A group starts at the end of
the stream the first time it runs: events from before the plugin was installed are not replayed.
Working with other plugins
Two mechanisms, depending on how strongly you depend on the other plugin:
requiresin the manifest maps package names to npm version ranges ({ "@rapidmx/meet-plugin": ">=0.5.0" }). The server installs and enables the required plugins along with yours, loads them first, and won't let an administrator disable or uninstall them while yours is enabled.PluginRegistry.isActive(name)from@rapidmx/restapitells you at run time whether another plugin is loaded in this process. Autodiscover uses it to advertise ActiveSync and MAPI only when those plugins are installed, without requiring either.