{"_id":"@astratra/notify","_rev":"3-9bd701dbc2d9de0cab4328186d4e8f12","name":"@astratra/notify","dist-tags":{"latest":"1.0.0"},"versions":{"0.2.0":{"name":"@astratra/notify","version":"0.2.0","keywords":["astratra","email","sms","push","notifications","transactional"],"license":"MIT","_id":"@astratra/notify@0.2.0","maintainers":[{"name":"emch99","email":"emchkongo@gmail.com"}],"dist":{"shasum":"61a32cb41feea047e667e36e4696d9afd9331640","tarball":"https://registry.npmjs.org/@astratra/notify/-/notify-0.2.0.tgz","fileCount":10,"integrity":"sha512-clxVaKaME91bJuUTwDrsSHz0/lWCGuFmE5tTojx05TcBCNzZ9iWmLK0pPeNAuqSoAgBAL8rQrtp1t/eFAf0zIg==","signatures":[{"sig":"MEYCIQDgznxchqeR6FJ/50e/DzvvpSyG2je9X0QRL4O7ACuiJQIhANe9AEuvxRLK2v1yhXZJp6XakU9NZ5IvY2aXATKaMbOx","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":36959},"jest":{"testMatch":["**/__tests__/**/*.test.js"]},"main":"src/index.js","type":"commonjs","types":"src/index.d.ts","engines":{"node":">=20"},"gitHead":"a3f7c737294561ba8651e14d507a18b41e199999","scripts":{"test":"jest"},"_npmUser":{"name":"emch99","email":"emchkongo@gmail.com"},"_npmVersion":"11.13.0","description":"Outbound messages done once — email, SMS and push. Transport injected, never throws, header-injection safe, loud simulation when unconfigured, dead push subscriptions handed back for pruning.","directories":{},"_nodeVersion":"24.16.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"jest":"30.4.2"},"_npmOperationalInternal":{"tmp":"tmp/notify_0.2.0_1787666097906_0.5450342338770935","host":"s3://npm-registry-packages-npm-production"}},"0.3.0":{"name":"@astratra/notify","version":"0.3.0","keywords":["astratra","email","sms","push","notifications","transactional"],"license":"MIT","_id":"@astratra/notify@0.3.0","maintainers":[{"name":"emch99","email":"emchkongo@gmail.com"}],"dist":{"shasum":"68835541458848816374b1d79e39ec8036ee1b01","tarball":"https://registry.npmjs.org/@astratra/notify/-/notify-0.3.0.tgz","fileCount":12,"integrity":"sha512-o0hFYm4oy4YcA/Dgw0eUBxgZ/IDOyJMG8WRcpE1mIsxeoE6exk0Rd0UUsA2Yj6qQKe6bTbmwJGAmG2Lw2npitQ==","signatures":[{"sig":"MEUCIFjtXApUBkCn20ApeeBzivaAbllTPdAtAkd7Z5mVqAjYAiEAju4j/VLI/rFqKZlnM+VXKxV2sgmBemLb2zpmZcY1OPc=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"sig":"MEQCIFhmV63VjhH7gdMxMxcTkbuWC5ik354os59YKewjYtsiAiAV4TIQhF+0LvPrelt53bucaFMDe9NI5Rl57/FQ8edCgw==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":71448},"jest":{"testMatch":["**/__tests__/**/*.test.js"]},"main":"src/index.js","type":"commonjs","types":"src/index.d.ts","engines":{"node":">=20"},"gitHead":"a6eda5bf009d1d47caff00955940c6d2303bee2c","scripts":{"test":"jest"},"_npmUser":{"name":"emch99","email":"emchkongo@gmail.com"},"_npmVersion":"11.13.0","description":"Outbound messages done once — email, SMS and push. Transport injected, never throws, header-injection safe, loud simulation when unconfigured, dead push subscriptions handed back for pruning.","directories":{},"_nodeVersion":"24.16.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"jest":"30.4.2"},"_npmOperationalInternal":{"tmp":"tmp/notify_0.3.0_1789991540894_0.2196026569594316","host":"s3://npm-registry-packages-npm-production"}},"1.0.0":{"_id":"@astratra/notify@1.0.0","dist":{"shasum":"8d5ba6e30dabb95e1edaba1733a9115c416021f9","tarball":"https://registry.npmjs.org/@astratra/notify/-/notify-1.0.0.tgz","fileCount":12,"integrity":"sha512-E08kGr3Z+G/SqeyVKvdt1WqDVSG/CNeDj/MFcQusltSwSxFsHwjRgqCxtE5lYsquWobiPuhTjrfPcOGJyMiDMQ==","signatures":[{"sig":"MEUCIQDECufz4AAAJ8g62dF16SxUs7wN/JpDQeiJAkEm6EEDxQIgcqXzpM2kPFZzLrsc+t9gTpHu/lQHXiiWvoyb4dFLleg=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEYCIQCgTOwW2wHs9tuGHM1cj58w9MDkwjuq9dlLjngmkgFlHwIhAIksXB3q6GjMC6ykJoWDmgfL6wvukWPqPiN+nhjEn38O"}],"unpackedSize":71448},"jest":{"testMatch":["**/__tests__/**/*.test.js"]},"main":"src/index.js","name":"@astratra/notify","type":"commonjs","types":"src/index.d.ts","engines":{"node":">=20"},"gitHead":"28e680aaed8dfdf7c5aa00c7dfbde74ab8e377de","license":"MIT","scripts":{"test":"jest"},"version":"1.0.0","_npmUser":{"name":"emch99","email":"emchkongo@gmail.com"},"keywords":["astratra","email","sms","push","notifications","transactional"],"_npmVersion":"11.13.0","description":"Outbound messages done once — email, SMS and push. Transport injected, never throws, header-injection safe, loud simulation when unconfigured, dead push subscriptions handed back for pruning.","directories":{},"maintainers":[{"name":"emch99","email":"emchkongo@gmail.com"}],"_nodeVersion":"24.16.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"jest":"30.4.2"},"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/notify_1.0.0_1790882277476_0.9601462003821302"}}},"time":{"created":"2026-08-25T13:54:57.749Z","modified":"2026-10-01T19:17:57.788Z","0.2.0":"2026-08-25T13:54:58.079Z","0.3.0":"2026-09-21T11:52:20.983Z","1.0.0":"2026-10-01T19:17:57.575Z"},"license":"MIT","keywords":["astratra","email","sms","push","notifications","transactional"],"description":"Outbound messages done once — email, SMS and push. Transport injected, never throws, header-injection safe, loud simulation when unconfigured, dead push subscriptions handed back for pruning.","maintainers":[{"name":"emch99","email":"emchkongo@gmail.com"}],"readme":"# @astratra/notify\n\nLes messages sortants, faits une fois : **e-mail, SMS, notifications poussées**.\n\nLe transport est toujours injecté — SMTP, une API HTTP, Twilio, Infobip,\nweb-push, Expo. Ce package apporte l'anneau autour, et c'est le même anneau\npour les trois canaux :\n\n- **rien ne lève jamais** — un message est la conséquence d'une action, pas\n  l'action ;\n- **l'absence de configuration est visible**, jamais silencieuse ;\n- **ce qui vient de l'utilisateur ne devient jamais une instruction**.\n\nAucune dépendance à l'exécution.\n\n---\n\n# E-mail\n\n## Un envoi raté ne fait jamais échouer l'appelant\n\nUn e-mail de confirmation est la **conséquence** d'une action. Quand la\ncommande est passée et l'argent encaissé, une coupure SMTP ne doit pas se\ntransformer en erreur 500 qui annonce au client que sa commande a échoué.\n\nDonc rien ne lève. Chaque appel renvoie un résultat que tu peux consulter,\njournaliser, ou ignorer.\n\n```js\nconst { createMailer } = require('@astratra/notify');\n\nconst mailer = createMailer({\n  channels: {\n    transactional: {\n      from: 'no-reply@acme.cd',\n      fromName: 'Acme',\n      send: (message) => transporter.sendMail(message),   // nodemailer, Brevo, Resend…\n    },\n    alerts: {\n      from: 'alertes@acme.cd',\n      send: (message) => alertTransporter.sendMail(message),\n    },\n  },\n});\n\nconst { sent, reason } = await mailer.send({\n  to: 'jean@ecole.cd',\n  subject: 'Votre commande est confirmée',\n  text, html,\n});\n```\n\nPlusieurs canaux, parce qu'un reçu et une alerte de sécurité n'ont pas à\nvoyager sur la même réputation d'expéditeur ni à partager les mêmes\nidentifiants.\n\nUn canal sans identifiants ne fait pas tomber l'application : il journalise et\nlaisse passer. Un produit en développement ne doit pas planter à chaque\ninscription faute de configuration SMTP.\n\n## L'injection d'en-têtes\n\nLes en-têtes sont séparés par des retours à la ligne. Un saut de ligne dans un\nsujet ne reste donc pas dans le sujet : il le **termine** et en commence un\nnouveau.\n\n```\nsubject = \"Commande confirmée\\r\\nBcc: tout@le-monde.cd\"\n```\n\nCe qui suit devient un en-tête à part entière. Un formulaire de contact devient\nun relais de spam, ou une copie silencieuse de chaque message. C'est une faille\nancienne, bien connue, et livrée tous les jours — parce que la chaîne a l'air\nparfaitement ordinaire dans un débogueur.\n\nTout ce qui atterrit dans un en-tête passe par le nettoyage : sujet, nom\nd'expéditeur, destinataires, `Reply-To`.\n\n**Les mots sont conservés, pas supprimés.** Un sujet qui perd la moitié de ses\nmots en silence est un rapport de bug que tu ne comprendras jamais.\n\n**Une adresse invalide est refusée, pas réparée.** Un `Reply-To` malformé casse\nsilencieusement toutes les réponses, et personne ne le signale puisque le\nmessage, lui, est bien arrivé.\n\n**Une seule mauvaise adresse dans une liste ne fait pas perdre les autres.** Les\nfournisseurs rejettent le message entier pour une adresse mal formée : une\nfaute de frappe coûterait tous les autres destinataires.\n\n## Le gabarit HTML\n\nL'e-mail n'est pas le web. Outlook met en page le HTML avec Word, Gmail retire\nles blocs `<style>`, et flexbox, grid, les variables CSS et les feuilles\nexternes sont tout simplement absents. Ce qui marche partout est ce qui marchait\nen 2005 : des tableaux imbriqués et des styles en ligne.\n\n```js\nconst { renderEmail, renderText } = require('@astratra/notify');\n\nconst blocks = [\n  { type: 'heading',   text: 'Code de modification' },\n  { type: 'paragraph', text: 'Votre code :' },\n  { type: 'code',      value: '482915' },\n  { type: 'button',    label: 'Ouvrir', url: 'https://app.acme.cd' },\n  { type: 'note',      text: 'Valable dix minutes.' },\n];\n\nawait mailer.send({\n  to, subject: 'Votre code',\n  html: renderEmail({ blocks, preheader: 'Votre code de sécurité', theme: { accent: '#2563eb' } }),\n  text: renderText({ blocks }),\n});\n```\n\nCe n'est pas un système de design : c'est le plus petit squelette qui arrive\nidentique dans Gmail, Outlook et Apple Mail, avec tes couleurs dessus.\n\n**Le bouton est un tableau.** Un `<a>` stylé s'effondre en lien nu dans Outlook,\nparce que le moteur de Word ignore le `padding` sur les éléments en ligne.\n\n**Le pré-en-tête** est la ligne affichée à côté du sujet dans la boîte de\nréception. Sans lui, les clients attrapent les premiers mots du corps — souvent\nun texte alternatif égaré.\n\n**La version texte n'est pas une politesse.** Un message sans partie texte est\nmoins bien noté par les filtres anti-spam, et certains lecteurs ne voient que\ncelle-là. La générer depuis les mêmes blocs garde les deux en phase.\n\n**Tout est échappé.** Ça mord bien avant qu'on essaie de t'attaquer : un client\nnommé « Dupont & Fils \\<SARL\\> » casse la mise en page de tous les messages où\nil apparaît. Le bloc `html` est la porte de sortie, nommée explicitement pour\nqu'elle ne s'ouvre jamais par accident.\n\n## Tester sans serveur mail\n\n```js\nconst { createCaptureChannel } = require('@astratra/notify');\n\nconst capture = createCaptureChannel({ from: 'no-reply@acme.cd' });\nconst mailer = createMailer({ channels: { transactional: capture } });\n\nawait service.confirmerCommande(id);\n\nexpect(capture.last().subject).toBe('Votre commande est confirmée');\n```\n\nUtile aussi en développement, où un envoi réel atteindrait une vraie personne.\n\n---\n\n# SMS\n\n```js\nconst { createSmsSender } = require('@astratra/notify');\n\nconst sms = createSmsSender({\n  transport: ({ to, text }) => twilioClient.messages.create({ to, from, body: text }),\n});\n\nconst { sent } = await sms.send('+243 810 000 000', `Votre code : ${code}`);\n```\n\n**Sans transport configuré, l'envoi est une simulation BRUYANTE.** Le message\npart dans le journal, étiqueté `SIMULATION`, et le résultat le dit\n(`simulated: true`). Une simulation qui a l'air réelle est pire qu'un échec :\nquelqu'un attend un code qui n'a jamais quitté le journal.\n\nDeux protections de plus : le numéro est nettoyé (espaces, points, tirets — ce\nqui casse les fournisseurs) sans prétendre le valider, et **le texte est\nplafonné** — un texte non borné concaténé dans un SMS est la façon dont un bug\ndevient une facture.\n\n---\n\n# Notifications poussées\n\n## La leçon qui compte : les abonnements morts\n\nUn abonnement push meurt en silence : application désinstallée, permission\nrévoquée, profil effacé. Le fournisseur répond 404 ou 410, et à partir de là\nchaque envoi vers cet abonnement échoue, pour toujours.\n\nLaissée seule, une liste d'abonnés n'accumule que des cadavres : les envois\nralentissent, les journaux d'erreurs se remplissent d'un bruit qui enterre les\nvrais échecs, et certains fournisseurs étranglent les expéditeurs dont le taux\nd'échec grimpe.\n\nUn abonnement mort n'est donc pas une erreur à journaliser — c'est un **fait à\ntraiter** :\n\n```js\nconst { createPushSender } = require('@astratra/notify');\n\nconst push = createPushSender({\n  transport: (subscription, payload) =>\n    webpush.sendNotification(subscription, JSON.stringify(payload)),\n  // LE point du module : l'abonnement mort t'est rendu pour suppression.\n  onGone: (subscription) => Subscriptions.deleteOne({ endpoint: subscription.endpoint }),\n  concurrency: 10, // défaut : 10 envois simultanés au maximum\n  timeoutMs: 30_000, // défaut : un fournisseur a 30 secondes pour répondre\n});\n\nconst { delivered, gone, failed } = await push.broadcast(subscriptions, payload);\n```\n\nTrois issues distinctes, jamais confondues : `delivered`, `gone` (mort, élagué),\n`failed` (panne passagère, avec sa raison). Un abonnement mort ou en panne\nn'arrête jamais les autres, et un élagage qui échoue ne transforme pas un `gone`\nen `failed`.\n\n`broadcast` utilise un nombre borné de workers : un fournisseur lent ne retient\nplus tous les abonnements placés derrière lui, sans pour autant déclencher une\nrafale non bornée. Un dépassement de `timeoutMs` compte comme `failed` et les\nautres envois continuent. La forme du rapport reste exactement\n`{ delivered, gone, failed, errors }` ; même si les envois finissent dans le\ndésordre, `errors` suit toujours l'ordre des abonnements d'entrée (et conserve\nsa limite historique de dix raisons).\n\nLe statut du fournisseur est cherché où qu'il l'ait mis — `statusCode`,\n`status`, `response.status` — parce que web-push et les clients HTTP ne sont pas\nd'accord entre eux.\n\n---\n\n# La boîte de notifications, côté serveur\n\n## Lire par pages, ranger, et ne jamais toucher au compte d'un autre\n\nTrois défauts, tous du même produit :\n\n- La liste rendait « les cinquante dernières », point. Au-delà, une\n  notification existait en base sans que personne ne puisse jamais la revoir.\n- La suppression prenait un identifiant. Un identifiant se devine, ou fuit —\n  dans une URL, un journal, une capture. La **seule** barrière entre\n  « supprimer la mienne » et « supprimer celle de n'importe qui » est le\n  propriétaire **dans la requête elle-même**, pas une vérification faite après\n  et oubliée sur la route suivante.\n- « Absente » et « à quelqu'un d'autre » doivent recevoir **la même réponse**.\n  Les distinguer dit à un inconnu quels identifiants existent.\n\n```js\nconst {\n  createNotificationInbox, createInboxHandlers, mountInbox,\n} = require('@astratra/notify');\n\nconst inbox = createNotificationInbox({\n  store: myInboxStore,                              // injecté, voir plus bas\n  isValidId: (id) => mongoose.isValidObjectId(id),  // 404 au lieu d'une erreur de conversion (500)\n  // pageSize: 50, maxPageSize: 100\n});\n\nconst router = express.Router();\nrouter.use(auth);\nmountInbox(router, createInboxHandlers(inbox, {\n  owner: (req) => req.user.id,   // la SESSION, jamais le corps ni la requête\n}));\napp.use('/api/notifications', router);\n```\n\n| Route | Réponse |\n|---|---|\n| `GET /?page=2&limit=20` | `{ notifications, unreadCount, pagination: { total, page, limit, totalPages } }` |\n| `GET /:id` | `{ notification }` ou 404 |\n| `DELETE /:id` | `{ unreadCount }` ou 404 — absente et étrangère : même 404 |\n| `DELETE /read` | `{ deleted, unreadCount }` — les **lues** seulement |\n| `PATCH /:id/read`, `PATCH /read-all` | `{ unreadCount }` |\n\n- **Sans paramètre, rien ne change** : première page de cinquante, comme les\n  appelants qui n'ont jamais paginé l'attendent. Une valeur absurde (`-4`,\n  `abc`) retombe sur le défaut, une trop grande est plafonnée.\n- **Le nombre de non-lues revient avec chaque suppression** : la pastille se\n  recale sans attendre le prochain relevé.\n- **Ranger n'efface pas ce qu'on n'a pas vu** : `DELETE /read` garde les\n  non-lues.\n- **`mountInbox` enregistre les chemins fixes AVANT `/:id`.** Sinon\n  `DELETE /read` est lu comme « supprimer la notification d'identifiant\n  \"read\" », répond 404, et le bouton de rangement ne fait rien sans rien dire.\n- Tes enveloppes de réponse passent par `respond: (res, status, body) => …`.\n\n### Le stockage est injecté\n\nChaque méthode reçoit `ownerId` et **doit l'appliquer dans la requête** :\n\n```js\nconst myInboxStore = {\n  list:  ({ ownerId, offset, limit }) => Notification.find({ userId: ownerId })\n           .sort({ createdAt: -1, _id: -1 })   // _id départage deux avis du même instant\n           .skip(offset).limit(limit).lean(),\n  count: ({ ownerId, read }) => Notification.countDocuments({ userId: ownerId, ...(read === undefined ? {} : { read }) }),\n  get:   ({ ownerId, id }) => Notification.findOne({ _id: id, userId: ownerId }).lean(),\n  remove: async ({ ownerId, id }) => (await Notification.deleteOne({ _id: id, userId: ownerId })).deletedCount,\n  removeRead: async ({ ownerId }) => (await Notification.deleteMany({ userId: ownerId, read: true })).deletedCount,\n  markRead: async ({ ownerId, id }) => (await Notification.updateOne({ _id: id, userId: ownerId }, { read: true })).matchedCount,\n  markAllRead: async ({ ownerId }) => (await Notification.updateMany({ userId: ownerId, read: false }, { read: true })).modifiedCount,\n};\n```\n\nSans départage par identifiant, une base rend les égalités dans l'ordre qui\nlui plaît, et peut changer d'avis d'une requête à l'autre : une notification\napparaît alors sur deux pages, ou sur aucune.\n\n`createMemoryInboxStore()` est l'adaptateur de référence, pour les tests : il\n**applique** le filtre propriétaire comme une base le ferait, et refuse une\nrequête sans propriétaire. Une boîte qui oublierait de le passer atteindrait\nici l'autre compte exactement comme en production — c'est ce qui rend les\ntests honnêtes.\n\n## Des notifications traduites, et un texte neutre pour l'écran verrouillé\n\nUne notification produite par le serveur (une note, une facture, un résultat)\nest une clé et des paramètres ; le titre et le message sont rendus pour chaque\ndestinataire, dans sa langue. **Les textes sont à toi** : ce package n'en\ncontient aucun.\n\nLa leçon qui justifie un module plutôt qu'un dictionnaire : un push s'affiche\nsur un **écran verrouillé**, sous les yeux de quiconque tient ou regarde le\ntéléphone. Une décision de fin d'année sur un enfant y est apparue en toutes\nlettres — sous un titre pourtant rendu neutre, puisque le corps était le\nmessage lui-même. Une entrée peut donc déclarer `pushBody` : une ligne neutre\nqui remplace le message **dans le push seulement**. La boîte, l'écran et\nl'e-mail — lu dans une boîte personnelle, une fois déverrouillé — gardent le\nmessage complet.\n\n```js\nconst { createNotificationCatalog } = require('@astratra/notify');\n\nconst catalog = createNotificationCatalog({\n  languages: ['fr', 'en'],\n  entries: [grades, billing],   // un objet par domaine ; une clé en double lève au démarrage\n});\n\n// grades.js\nmodule.exports = {\n  year_result: {\n    fr: (p) => ({ title: 'Résultat de fin d\\'année disponible', message: `${p.student} …`, pushBody: 'Ouvrez l\\'application pour le consulter.' }),\n    en: (p) => ({ title: 'End-of-year result available',       message: `${p.student} …`, pushBody: 'Open the app to view it.' }),\n  },\n};\n\nconst n = catalog.render('year_result', recipientLanguage, { student: 'Ada' });\nawait inboxStore.create({ ownerId, title: n.title, message: n.message }); // complet\nawait push.broadcast(devices, n.push);                                    // { title, body } neutre\n```\n\nUne langue non écrite pour une clé retombe sur la langue par défaut\n(`n.language` dit laquelle a été rendue) ; chaque entrée doit donc exister\ndans la langue par défaut. Une clé inconnue lève : c'est une faute de frappe\ndans le code.\n\n### L'audit\n\n```js\ntest('le catalogue des notifications', () => {\n  const report = catalog.audit({ params: SAMPLE_PARAMS });\n  expect(report.pushBodyGaps).toEqual([]);\n  expect(report.placeholders).toEqual([]);\n  expect(report.errors).toEqual([]);\n});\n```\n\n- `pushBodyGaps` : un corps neutre déclaré dans certaines langues et oublié\n  dans d'autres. La fuite n'existe que dans la langue oubliée — personne ne la\n  voit en relisant le catalogue dans la sienne.\n- `placeholders` : `undefined`, `null`, `NaN` ou `[object Object]` dans un\n  texte rendu — un paramètre attendu et jamais reçu.\n- `missing` : les langues non écrites, servies dans la langue par défaut.\n\n---\n\n## Ce que ce package ne fait pas\n\n- Il n'envoie **rien** lui-même : les transports sont injectés. Aucun SDK — ni\n  nodemailer, ni Twilio, ni web-push — n'est importé.\n- Il ne stocke pas les abonnements : il te rend les morts, tu les supprimes.\n  Il ne tient pas non plus de registre d'appareils par compte : tu choisis la\n  liste, `broadcast` l'envoie.\n- La boîte de notifications ne crée pas les notifications et ne les pousse\n  pas : elle lit, range et supprime ce que ton stockage contient.\n- Il ne fait ni file d'attente ni relance — voir `@astratra/resilience`.\n- Il ne gère pas le temps réel (websockets).\n- Il n'a pas d'avis sur ton identité visuelle, seulement sur ce qui survit aux\n  clients mail.\n\n## Tests\n\n```bash\nnpm test --workspace @astratra/notify\n```\n","readmeFilename":"README.md"}