mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-19 05:08:20 +02:00
Boîte mail de l'association : trois transports, une file d'attente
Le site savait composer de beaux messages et ne savait pas les envoyer : `email-client.ts` ne proposait que la copie ou le téléchargement d'un brouillon Outlook, complété à la main. `/backend/boite-mail` branche désormais la boîte de l'association, sur le modèle exact de l'écran Réseaux sociaux — secrets chiffrés, jamais renvoyés au navigateur, champ laissé vide qui conserve la valeur enregistrée, contrôle de santé qui ne lève jamais. Trois transports, un seul actif à la fois, désigné par l'administrateur — pas de cascade automatique : si Google se bloque, la bascule se voit. - **Google** passe par l'API Gmail plutôt que par SMTP avec XOAUTH2 : celui-ci exigerait `https://mail.google.com/`, portée *restreinte* et donc audit de sécurité, là où `gmail.send` est simplement *sensible*. - **Microsoft** passe par Graph : l'authentification basique SMTP est désactivée depuis 2024, y compris sur outlook.com et hotmail.com. - **SMTP** couvre le reste via `nodemailer`, seule dépendance ajoutée. `from_address` est **lu chez le fournisseur** et non saisi : Gmail expédie comme l'utilisateur authentifié, Graph comme la boîte. Une adresse d'un autre domaine ferait tomber SPF et DKIM. La file `mail_messages` porte un destinataire unique par ligne — la confidentialité d'une diffusion est structurelle, aucune copie partagée n'est possible. Elle est vidée par la boucle de fond, avec réclamation en `FOR UPDATE SKIP LOCKED`, réessais espacés et reprise des verrous laissés par un conteneur arrêté en plein envoi. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP
This commit is contained in:
19 files changed
+3997
-16
No files matched your search
@@ -43,6 +43,21 @@ export const contactStatusEnum = pgEnum("contact_status", [
|
||||
"archived",
|
||||
]);
|
||||
export const infoStatusEnum = pgEnum("info_status", ["draft", "published"]);
|
||||
export const mailProviderEnum = pgEnum("mail_provider", ["google", "microsoft", "smtp"]);
|
||||
export const mailStatusEnum = pgEnum("mail_status", [
|
||||
"queued",
|
||||
"sending",
|
||||
"sent",
|
||||
"failed",
|
||||
"cancelled",
|
||||
]);
|
||||
export const mailKindEnum = pgEnum("mail_kind", [
|
||||
"credentials",
|
||||
"information",
|
||||
"meeting",
|
||||
"studio",
|
||||
"test",
|
||||
]);
|
||||
|
||||
// ---- Categories (métiers) ----
|
||||
export const categories = pgTable("categories", {
|
||||
@@ -338,6 +353,69 @@ export const informationReads = pgTable(
|
||||
})
|
||||
);
|
||||
|
||||
// ---- Boîte mail de l'association ----
|
||||
// Une ligne par fournisseur, `is_active` désigne celui qui expédie. Les secrets
|
||||
// sont chiffrés (`src/lib/crypto.ts`) et ne ressortent jamais vers le
|
||||
// navigateur — même règle que `social_accounts`.
|
||||
export const mailAccounts = pgTable("mail_accounts", {
|
||||
id: serial("id").primaryKey(),
|
||||
provider: mailProviderEnum("provider").notNull().unique(),
|
||||
// Lue chez le fournisseur pour Google et Microsoft : on expédie comme la
|
||||
// boîte authentifiée, sinon SPF et DKIM tombent.
|
||||
fromAddress: varchar("from_address", { length: 200 }),
|
||||
fromName: varchar("from_name", { length: 200 }),
|
||||
isActive: boolean("is_active").notNull().default(false),
|
||||
appId: varchar("app_id", { length: 200 }),
|
||||
appSecret: text("app_secret"),
|
||||
accessToken: text("access_token"),
|
||||
refreshToken: text("refresh_token"),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
||||
smtpHost: varchar("smtp_host", { length: 200 }),
|
||||
smtpPort: integer("smtp_port"),
|
||||
smtpSecure: boolean("smtp_secure").notNull().default(true),
|
||||
smtpUser: varchar("smtp_user", { length: 200 }),
|
||||
smtpPassword: text("smtp_password"),
|
||||
connectedById: integer("connected_by_id").references(() => users.id, { onDelete: "set null" }),
|
||||
connectedAt: timestamp("connected_at", { withTimezone: true }),
|
||||
lastCheckAt: timestamp("last_check_at", { withTimezone: true }),
|
||||
lastCheckOk: boolean("last_check_ok"),
|
||||
lastCheckError: text("last_check_error"),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
// File d'attente d'envoi. `to_address` est un destinataire **unique** : une
|
||||
// diffusion produit autant de lignes que d'adhérents, et aucune adresse ne peut
|
||||
// donc apparaître aux yeux des autres.
|
||||
export const mailMessages = pgTable(
|
||||
"mail_messages",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
kind: mailKindEnum("kind").notNull(),
|
||||
status: mailStatusEnum("status").notNull().default("queued"),
|
||||
toAddress: varchar("to_address", { length: 200 }).notNull(),
|
||||
toName: varchar("to_name", { length: 200 }),
|
||||
subject: varchar("subject", { length: 300 }).notNull(),
|
||||
html: text("html").notNull(),
|
||||
text: text("text"),
|
||||
replyTo: varchar("reply_to", { length: 200 }),
|
||||
informationId: integer("information_id").references(() => informations.id, { onDelete: "set null" }),
|
||||
meetingId: integer("meeting_id").references(() => meetings.id, { onDelete: "set null" }),
|
||||
memberId: integer("member_id").references(() => members.id, { onDelete: "set null" }),
|
||||
createdById: integer("created_by_id").references(() => users.id, { onDelete: "set null" }),
|
||||
provider: mailProviderEnum("provider"),
|
||||
attempts: integer("attempts").notNull().default(0),
|
||||
nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
lockedAt: timestamp("locked_at", { withTimezone: true }),
|
||||
sentAt: timestamp("sent_at", { withTimezone: true }),
|
||||
error: text("error"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
dueIdx: index("mail_messages_due_idx").on(table.status, table.nextAttemptAt),
|
||||
informationIdx: index("mail_messages_information_idx").on(table.informationId),
|
||||
})
|
||||
);
|
||||
|
||||
// ---- Relations ----
|
||||
export const membersRelations = relations(members, ({ one, many }) => ({
|
||||
category: one(categories, {
|
||||
@@ -440,4 +518,8 @@ export type PastMeeting = typeof pastMeetings.$inferSelect;
|
||||
export type PastMeetingPhoto = typeof pastMeetingPhotos.$inferSelect;
|
||||
export type ImageConsent = typeof imageConsents.$inferSelect;
|
||||
export type Information = typeof informations.$inferSelect;
|
||||
export type MailAccount = typeof mailAccounts.$inferSelect;
|
||||
export type MailProvider = (typeof mailProviderEnum.enumValues)[number];
|
||||
export type MailMessage = typeof mailMessages.$inferSelect;
|
||||
export type MailKind = (typeof mailKindEnum.enumValues)[number];
|
||||
export type InformationRead = typeof informationReads.$inferSelect;
|
||||
Reference in new issue
Block a user