Référence
Moteurs : e-mail, notifications, calendrier, recherche, validations
Mis à jour le 3 septembre 2026Plateforme v1 Markdown brut
Le principe
Une app n’envoie pas d’e-mail, ne pousse pas de notification et n’écrit pas
dans le calendrier elle-même : elle demande à Capibara de le faire, par
ctx, et la plateforme applique ses règles (destinataires liés à
l’organisation, désinscription, quotas, liens sanitisés, droits d’accès).
Chaque moteur correspond à une capability du manifeste — sans elle, l’appel
est refusé — et exige du code hébergé (main).
| Moteur | ctx.… |
Capability | Quota (par installation, par jour UTC) |
|---|---|---|---|
| E-mail transactionnel | ctx.mail.send |
mail.send |
Gratuit 20 · Pro 200 · Business 1 000 |
| Notifications | ctx.notify.user / permission / admins |
notify.send |
Gratuit 200 · Pro 2 000 · Business 10 000 |
| Calendrier | ctx.calendar.project / remove / clear |
calendar.project |
500 projections par app |
| Recherche ⌘K | ctx.search.index / remove / clear |
search.index |
2 000 fiches par app |
| Validations | ctx.approvals.request / get / cancel |
approvals.request |
200 demandes en attente par app |
Un quota atteint renvoie une erreur explicite (Quota … atteint) — votre
code la reçoit comme une exception, le journal de l’app la garde. Les quotas
vivent dans un réglage de la plateforme et peuvent évoluer sans nouvelle
version de votre app.
E-mail transactionnel — ctx.mail.send
La règle : jamais un module qui spamme. Un e-mail d’app est un message
transactionnel adressé à une personne qui a une relation avec
l’organisation — jamais une campagne (la newsletter reste le module Newsletter
de l’organisation, avec ses propres règles).
await ctx.mail.send({
to: [{ userId: viewer.userId }, { partyId: contact.id }],
subject: 'Votre relevé de la semaine',
blocks: [
{ type: 'title', text: 'Relevé hebdomadaire' },
{ type: 'text', text: 'Bonjour,\n\nvoici le résumé de la semaine.' },
{ type: 'meta', items: [{ label: 'Période', value: '1–7 septembre' }] },
{ type: 'summary', rows: [{ label: 'Total', value: '12 interventions', strong: true }] },
{ type: 'button', label: 'Ouvrir le détail', href: '/apps/mon-app/releves' },
{ type: 'note', text: 'Ce message est envoyé automatiquement chaque lundi.' },
],
key: 'releve.hebdo',
});
// → { ok: true, queued: 2, suppressed: 0, remainingToday: 198 }
Destinataires (to, ≤ 5 par appel) — chacun doit être lié à
l’organisation :
| Forme | Résolu vers |
|---|---|
{ userId } |
un utilisateur actif de l’espace (ex. viewer.userId) |
{ partyId } |
une fiche CRM (contact ou société) qui a un e-mail et n’est pas anonymisée |
{ memberId } |
un membre actif du site de l’organisation |
{ email } |
une adresse qui figure dans l’une de ces trois tables — sinon refus not_related |
Contenu en blocs — jamais de HTML libre. Le texte est échappé, les liens
des boutons sont vérifiés : chemin interne de Capibara (/apps/…), https://,
mailto: ou tel: ; tout autre lien rend le bouton en simple texte. L’e-mail
est habillé par la coque de l’organisation (logo, couleur), signé par
l’identité d’expéditeur de la plateforme (jamais par votre app), et porte en
pied « Message envoyé par l’app « X » installée par <organisation> » avec un
lien de préférences.
Ce que fait la plateforme : file d’envoi de l’organisation (priorité
intermédiaire — après le transactionnel de Capibara et du tenant, avant les
campagnes), en-têtes de désinscription en un clic (List-Unsubscribe),
catégorie de préférences propre à votre app (app.<clé>) — une personne peut
couper les e-mails de votre app sans toucher au reste ; ces adresses sont
écartées avant l’envoi (suppressed) et ne consomment pas le quota. Plafond
existant de 4 sollicitations par destinataire et par 7 jours, toutes apps et
modules confondus.
Pièces jointes : pas en v1 (utilisez un bouton vers ctx.files.url(id)).
Notifications — ctx.notify
Une notification apparaît dans la cloche de Capibara (et en push sur
l’application installée), sous le type app.<clé>.<key>, avec l’icône des
apps. Trois cibles :
await ctx.notify.user(viewer.userId, { title: 'Export prêt', link: '/apps/mon-app/exports' });
await ctx.notify.permission('crm.contacts:write', { title: '3 fiches à vérifier', severity: 'warning', key: 'a-verifier' });
await ctx.notify.admins({ title: 'Clé API expirée', body: 'Renseignez une nouvelle clé dans les réglages de l’app.', severity: 'destructive' });
user: une personne de l’espace (jamais un compte de service).permission: toutes les personnes dont les rôles couvrent
ressource:action(≤ 50, les administrateurs compris).admins: les administrateurs de l’organisation.linkdoit être un chemin interne (défaut : la page de votre app).- Le mode « Ne pas déranger » de chacun est respecté (persistée, sans push).
Calendrier — ctx.calendar
Vos dates apparaissent dans le calendrier de l’organisation, badge « App »,
entrée de légende « Apps », source app:<clé> — en lecture (la fiche renvoie
vers votre app pour qui a le droit « utiliser l’app »).
await ctx.calendar.project({
id: 'inspection:42', // stable : reprojeter le même id met à jour
title: 'Inspection annuelle — site de Lyon',
startsAt: '2026-10-01T09:00:00.000Z',
endsAt: '2026-10-01T12:00:00.000Z', // défaut : +1 h (ou la journée si allDay)
location: 'Lyon',
color: '#7c3aed',
visibility: 'ORG', // ou 'PRIVATE' + ownerUserId
});
await ctx.calendar.remove('inspection:42');
await ctx.calendar.clear(); // toutes les projections de l'app
Bornes : 500 projections par app, 366 jours de durée maximum, titre 200
caractères, description 2 000. Une projection PRIVATE n’est visible que de
son propriétaire.
Recherche ⌘K — ctx.search
Rendez vos fiches trouvables dans la recherche transversale de l’organisation :
un groupe au nom de votre app, visible des personnes qui ont « utiliser
l’app » (app.<clé>:read). Sans Meilisearch, le repli base de données cherche
dans le texte replié (accents et casse ignorés).
await ctx.search.index({ id: 'client:12', title: 'Dupont & fils', subtitle: 'Client premium', text: 'Contrat 2026, Lyon', href: '/apps/mon-app/clients/12' });
await ctx.search.remove('client:12');
Bornes : 2 000 fiches par app, texte 2 000 caractères, href interne (défaut :
la page de l’app). Les fiches vivent chez l’organisation (collection réservée
$search) et disparaissent avec la désinstallation.
Validations — ctx.approvals
Votre app peut demander à un humain de trancher : la demande arrive dans la
cloche Validations de Capibara (titre « Nom de l’app · votre titre »), et la
décision revient à votre code par l’événement approval.decided — déclarez-le
dans events du manifeste.
// Demander (une seule demande EN ATTENTE par sujet + subjectId ; la seconde renvoie la première)
const a = await ctx.approvals.request({
subject: 'refund', // kebab-case → sujet app.<clé>.refund
subjectId: 'order:42',
title: 'Rembourser 120 € à Dupont ?',
summary: 'Retour reçu le 3 septembre, produit intact.',
approver: { resource: 'billing-fr', action: 'write' }, // défaut : tenant:write (administrateur)
});
// a = { id, status: 'PENDING', … }
// Recevoir la décision
export default defineApp({
events: {
'approval.decided': async ({ ctx, event }) => {
const { requestId, subjectId, decision, comment } = event.data;
if (decision === 'APPROVED') await ctx.data.refunds.set({ orderId: subjectId, status: 'approved' }, requestId);
},
},
});
Seule la personne qui porte la permission approver peut décider. get(id)
relit l’état, cancel(id) retire une demande encore en attente. Vous ne
recevez que les décisions de vos demandes — jamais celles des modules
(congés, bons de commande…).
Paiements — ctx.capibara.billing.*
Une app n’encaisse jamais elle-même : elle remet au client un lien vers la
page de paiement de l’organisation (rail marchand de Capibara — direct
charge sur le compte Stripe de l’organisation, commission de la plateforme
selon sa formule, montant figé par Stripe). Trois façades, sous le compte de
service de l’app :
| Façade | Scope | Ce qu’elle fait |
|---|---|---|
billing.documents.payLink({ id, installmentId?, returnUrl? }) |
billing-fr.documents:write |
Lien de paiement d’une facture ÉMISE (ou d’une échéance) sur le site de l’organisation (https://<hôte de l'org>/payer-document/…). Refus honnête si la facture n’est pas payable (déjà réglée, avoir, montant < 0,50 €, Stripe non relié). |
billing.charges.create({ amountCents, label?, ttlMinutes?, returnUrl? }) |
billing-fr.pos:write |
Encaissement par carte (montant TTC + libellé), réglé par le client sur la page de paiement de l’organisation — même rail que son TPE virtuel, valable 5 à 15 min, attribué à l’app dans son journal. |
billing.charges.get({ id }) |
billing-fr.pos:read |
État de l’encaissement (PENDING, PAID, EXPIRED, CANCELED). |
returnUrl : après le règlement, la page propose « Retourner sur <votre
site> ». L’URL doit être en HTTPS et son hôte déclaré dans returnHosts du
manifeste (liste blanche, ≤ 5) — sinon la façade refuse. Elle voyage signée
(?r=<jeton>), jamais en clair : la page ne suit pas une URL brute.
Pour savoir qu’un paiement est arrivé : déclarez sales.document.paid ou
pos.charge.paid dans events (code hébergé) ou souscrivez-les en
webhook (backend externe — avec les liens de documents et
l’identité du client si vous cochez ces options).
actions: {
'facture.payer': async ({ ctx, params }) => {
const { url } = await ctx.capibara.billing.documents.payLink({
id: String(params.documentId),
returnUrl: 'https://boutique.exemple.fr/merci', // hôte déclaré dans returnHosts
});
return { navigate: url };
},
'acompte.encaisser': async ({ ctx }) => {
const charge = await ctx.capibara.billing.charges.create({ amountCents: 4500, label: 'Acompte atelier' });
return { toast: { text: `Code ${charge.formattedCode} — ${charge.url}` } };
},
},
Tester en local
Le harnais @capibara-dev/sdk/testing simule les cinq moteurs en mémoire :
const t = createTestContext();
await runAction(app, t, 'envoyer', {});
t.mails[0].subject; // e-mails « envoyés »
t.notifications[0].target; // { kind: 'admins' }
t.calendar.get('inspection:42'); // projection
t.searchDocs.size; // fiches indexées
const req = [...t.approvals.values()][0];
await runEvent(app, t, t.decideApproval(req.id, 'APPROVED')); // rejoue la décision
Ce qui est volontairement hors du contrat
- Pas de liste de diffusion, pas d’accès aux abonnés newsletter, pas de Cci de
masse : une app n’écrit qu’à des personnes liées, cinq par appel. - Pas de HTML libre dans les e-mails, pas de lien vers un domaine arbitraire
autre qu’enhttps://. - Pas d’e-mail ni de notification à « tout le monde » : ciblez une personne,
une permission ou les administrateurs. - Pièces jointes,
ctx.events.emit,ctx.ai.generate: différés.