Hello !
J’ai développé un widget personnalisé pour la composition d’emails en batch que je vous partage. Il est particulièrement utile lorsque vous devez envoyer un même email à une longue liste de contacts stockée dans une table tout en préservant leur confidentialité grâce en les mettant en BCC.
Fonctionnalités :
Composition d’emails pour plusieurs destinataires en BCC
Gestion des destinataires (suppression/restauration de contacts)
Ajout manuel de destinataires
Validation des adresses email
Ouverture dans votre client email par défaut pour vérification et envoi
Essayez-le :
Démo : Custom Widget Portfolio - Grist
Code : grist-custom-widgets/batch-emailing at main · agrippaharfleur/grist-custom-widgets · GitHub
10 « J'aime »
Top ! Merci Amandine.
(Pour info, il y a un petit problème avec ton lien Git)
Ah mince bien vu, il a sauté quand j’ai renommé le dossier :s
Je viens de corriger ! Merci @Mathieu !
1 « J'aime »
audezu
Février 24, 2025, 5:48
4
Génial Amandine, ça fonctionne parfaitement et c’est simple à comprendre ! Vraiment top, merci beaucoup. Cela va nous servir dans un Grist pour une petite commune (et probablement plein à venir).
Je l’ai traduit en FR et j’ai déplacé la partie « ajout manuel de destinataire » dans la section « Destinataires ». Je mets le code ici à toutes fins utiles.
html
<!DOCTYPE html>
<html lang="">
<head>
<script src="https://docs.getgrist.com/grist-plugin-api.js"></script>
<style>
/* Reset and base styles */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
font-size: 14px;
line-height: 1.5;
color: #24292f;
background-color: #ffffff;
padding: 16px;
}
/* Container styles */
.email-widget {
max-width: 800px;
margin: 0 auto;
border: 1px solid #d0d7de;
border-radius: 6px;
}
/* Header styles */
.widget-header {
padding: 16px;
background-color: #f6f8fa;
border-bottom: 1px solid #d0d7de;
border-top-left-radius: 6px;
border-top-right-radius: 6px;
}
.widget-header h2 {
font-size: 16px;
font-weight: 600;
margin-bottom: 8px;
}
/* Form styles */
.form-group {
padding: 16px;
border-bottom: 1px solid #d0d7de;
}
.form-group label {
display: block;
font-weight: 600;
margin-bottom: 8px;
}
.warning-text {
color: #57606a;
font-size: 13px;
margin-bottom: 12px;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid #d0d7de;
border-radius: 6px;
font-size: 14px;
line-height: 20px;
}
.form-group input:focus,
.form-group textarea:focus {
border-color: #0969da;
outline: none;
box-shadow: 0 0 0 3px rgba(9, 105, 218, 0.3);
}
/* Email editor */
.email-editor {
min-height: 200px;
resize: vertical;
}
/* Recipients list */
.recipients-list {
margin-top: 8px;
padding: 8px;
background-color: #f6f8fa;
border: 1px solid #d0d7de;
border-radius: 6px;
max-height: 150px;
overflow-y: auto;
}
.recipients-count {
color: red;
font-size: 12px;
margin-top: 4px;
}
/* Button styles */
.btn {
display: inline-block;
padding: 8px 16px;
font-size: 14px;
font-weight: 600;
line-height: 20px;
white-space: nowrap;
vertical-align: middle;
cursor: pointer;
user-select: none;
border: 1px solid;
border-radius: 6px;
appearance: none;
text-decoration: none;
}
.btn-primary {
color: #ffffff;
background-color: #2da44e;
border-color: rgba(27, 31, 36, 0.15);
}
.btn-primary:hover {
background-color: #2c974b;
}
.btn-secondary {
color: #24292f;
background-color: #f6f8fa;
border-color: rgba(27, 31, 36, 0.15);
}
.btn-secondary:hover {
background-color: #f3f4f6;
}
.widget-footer {
padding: 16px;
text-align: right;
}
/* Status message */
.status-message {
margin-top: 8px;
padding: 8px;
border-radius: 6px;
display: none;
}
.status-message.success {
background-color: #dafbe1;
color: #1a7f37;
display: block;
}
.status-message.error {
background-color: #ffebe9;
color: #cf222e;
display: block;
}
/* Recipient styles */
.recipient {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 8px;
margin: 2px 0;
border-radius: 4px;
background-color: #ffffff;
}
.recipient:hover {
background-color: #f6f8fa;
}
.recipient .actions {
display: flex;
gap: 8px;
}
.recipient.removed {
text-decoration: line-through;
color: #57606a;
}
.btn-icon {
padding: 4px 8px;
background: none;
border: none;
cursor: pointer;
color: #57606a;
border-radius: 4px;
}
.btn-icon:hover {
background-color: #f3f4f6;
color: #24292f;
}
.btn-danger {
color: #cf222e;
}
.btn-danger:hover {
background-color: #ffebe9;
}
.btn-success {
color: #1a7f37;
}
.btn-success:hover {
background-color: #dafbe1;
}
.manual-add-section {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.manual-input {
flex-grow: 1;
}
/* Lists styles */
.removed-list, .manual-list {
margin-top: 16px;
padding: 8px;
background-color: #f6f8fa;
border: 1px solid #d0d7de;
border-radius: 6px;
}
.removed-list h3, .manual-list h3 {
font-size: 14px;
font-weight: 600;
margin-bottom: 8px;
color: #57606a;
}
.removed-emails, .manual-emails {
max-height: 150px;
overflow-y: auto;
}
.manual-email {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 8px;
margin: 2px 0;
border-radius: 4px;
background-color: #ffffff;
}
.tag {
font-size: 12px;
padding: 2px 6px;
border-radius: 10px;
margin-left: 8px;
background-color: #ddf4ff;
color: #0969da;
}
</style>
<title></title>
</head>
<body>
<div class="email-widget">
<div class="widget-header">
<h2>Envoi d'e-mails par lots</h2>
<p>Configurer et envoyer des e-mails à plusieurs destinataires</p>
</div>
<div class="form-group">
<label for="replyTo">Adresse e-mail de réponse</label>
<input type="email" id="replyTo" placeholder="Entrez l'adresse e-mail de l'expéditeur">
</div>
<div class="form-group">
<label>Destinataires (bcc)</label>
<p class="warning-text"><i>💡 Pensez à ajouter votre colonne contenant les e-mails dans la configuration du
widget. Des filtres doivent ensuite être appliqués sur la vue de ce widget pour affecter la liste des
destinataires. Veuillez vérifier que le nombre de destinataires correspond aux résultats attendus.</i></p>
<div class="form-group">
<div class="manual-add-section">
<input type="email" id="newEmail" placeholder="Entrer l'adresse e-mail" class="manual-input">
<button id="addEmail" class="btn btn-secondary">Ajouter</button>
</div>
<div id="manualList" class="manual-list" style="display: none;">
<h3>Destinataires ajoutés manuellement</h3>
<div id="manualEmails" class="manual-emails">
<!-- Manually added emails will be listed here -->
</div>
</div>
<div id="recipientsList" class="recipients-list">
<!-- Recipients will be listed here -->
</div>
<div class="recipients-count">
<b><span id="recipientsCount">0</span></b> destinataires sélectionnés
</div>
<div id="removedList" class="removed-list" style="display: none;">
<h3>Destinataires supprimés</h3>
<div id="removedEmails" class="removed-emails">
<!-- Removed emails will be listed here -->
</div>
</div>
</div>
<div class="form-group">
<label for="subject">Objet de l'e-mail</label>
<input type="text" id="subject" placeholder="Entrer le sujet de l'e-mail">
</div>
<div class="form-group">
<label for="emailContent">Contenu de l'e-mail</label>
<textarea id="emailContent" class="email-editor" placeholder="Entrez le contenu de l'e-mail ici..."></textarea>
</div>
<div class="widget-footer">
<button id="sendEmail" class="btn btn-primary">Créer un e-mail</button>
</div>
<div id="statusMessage" class="status-message"></div>
</div>
</body>
</html>
js
// Initialize Grist API with required columns
grist.ready({
columns: [
{
name: "emails",
title: "Email Column",
type: "Text",
description: "Column containing email addresses",
optional: false
}
],
requiredAccess: 'read table'
});
// Global variables to store state
let emailList = [];
let removedEmails = new Set();
let manuallyAddedEmails = new Set();
let mappedColumns = null;
// DOM Elements
const recipientsList = document.getElementById('recipientsList');
const recipientsCount = document.getElementById('recipientsCount');
const removedEmailsList = document.getElementById('removedEmails');
const removedListSection = document.getElementById('removedList');
const manualEmailsList = document.getElementById('manualEmails');
const manualListSection = document.getElementById('manualList');
const replyToInput = document.getElementById('replyTo');
const subjectInput = document.getElementById('subject');
const emailContent = document.getElementById('emailContent');
const newEmailInput = document.getElementById('newEmail');
const addEmailBtn = document.getElementById('addEmail');
const sendEmailBtn = document.getElementById('sendEmail');
const statusMessage = document.getElementById('statusMessage');
// Handle record updates from Grist
grist.onRecords(function(records, mappings) {
mappedColumns = grist.mapColumnNames(records[0]);
if (mappedColumns) {
// Extract email addresses from records
const newEmailList = [...new Set(records
.map(record => grist.mapColumnNames(record).emails)
.filter(email => email && email.trim() !== '')
)];
// Add manually added emails to the list
emailList = [...new Set([...newEmailList, ...manuallyAddedEmails])];
updateRecipientsDisplay();
updateRemovedEmailsDisplay();
updateManualEmailsDisplay();
} else {
showError("Veuillez choisir la colonne Email dans la configuration du widget.");
}
});
// Create recipient element
function createRecipientElement(email, isRemoved = false) {
const div = document.createElement('div');
div.className = `recipient${isRemoved ? ' removed' : ''}`;
div.innerHTML = `
<span>${email}</span>
<div class="actions">
${isRemoved ?
`<button class="btn-icon btn-success" data-action="restore" title="Restore">↩</button>` :
`<button class="btn-icon btn-danger" data-action="remove" title="Remove">×</button>`
}
</div>
`;
// Add event listeners for actions
const actionBtn = div.querySelector('[data-action]');
actionBtn.addEventListener('click', () => {
if (isRemoved) {
restoreEmail(email);
} else {
removeEmail(email);
}
});
return div;
}
// Update the recipients display
function updateRecipientsDisplay() {
recipientsList.innerHTML = '';
const activeEmails = emailList.filter(email => !removedEmails.has(email) && !manuallyAddedEmails.has(email));
activeEmails.forEach(email => {
recipientsList.appendChild(createRecipientElement(email));
});
recipientsCount.textContent = activeEmails.length + manuallyAddedEmails.size;
}
// Update removed emails display
function updateRemovedEmailsDisplay() {
removedEmailsList.innerHTML = '';
if (removedEmails.size > 0) {
removedListSection.style.display = 'block';
[...removedEmails].forEach(email => {
removedEmailsList.appendChild(createRecipientElement(email, true));
});
} else {
removedListSection.style.display = 'none';
}
}
// Update manual emails display
function updateManualEmailsDisplay() {
manualEmailsList.innerHTML = '';
if (manuallyAddedEmails.size > 0) {
manualListSection.style.display = 'block';
[...manuallyAddedEmails].forEach(email => {
const div = document.createElement('div');
div.className = 'manual-email';
div.innerHTML = `
<span>${email}<span class="tag">Manual</span></span>
<div class="actions">
<button class="btn-icon btn-danger" data-action="remove" title="Remove">×</button>
</div>
`;
const removeBtn = div.querySelector('[data-action="remove"]');
removeBtn.addEventListener('click', () => {
manuallyAddedEmails.delete(email);
emailList = emailList.filter(e => e !== email);
updateManualEmailsDisplay();
updateRecipientsDisplay();
showSuccess(`Removed ${email} from manual recipients`);
});
manualEmailsList.appendChild(div);
});
} else {
manualListSection.style.display = 'none';
}
}
// Remove email from active list
function removeEmail(email) {
removedEmails.add(email);
updateRecipientsDisplay();
updateRemovedEmailsDisplay();
showSuccess(`Removed ${email} from recipients`);
}
// Restore email to active list
function restoreEmail(email) {
removedEmails.delete(email);
updateRecipientsDisplay();
updateRemovedEmailsDisplay();
showSuccess(`Restored ${email} to recipients`);
}
// Add new email manually
function addNewEmail(email) {
if (!isValidEmail(email)) {
showError("Merci d'entrer une addresse email valide");
return;
}
if (emailList.includes(email)) {
if (removedEmails.has(email)) {
restoreEmail(email);
} else {
showError("Cet email est déjà dans la liste des destinataires");
}
return;
}
manuallyAddedEmails.add(email);
emailList.push(email);
updateManualEmailsDisplay();
updateRecipientsDisplay();
newEmailInput.value = '';
showSuccess(`${email} ajouté aux destinataires.`);
}
// Show success message
function showSuccess(message) {
statusMessage.className = 'status-message success';
statusMessage.textContent = message;
setTimeout(() => {
statusMessage.className = 'status-message';
statusMessage.textContent = '';
}, 3000);
}
// Show error message
function showError(message) {
statusMessage.className = 'status-message error';
statusMessage.textContent = message;
}
// Validate email address
function isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
// Event Listeners
addEmailBtn.addEventListener('click', () => {
const email = newEmailInput.value.trim();
addNewEmail(email);
});
newEmailInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
const email = newEmailInput.value.trim();
addNewEmail(email);
}
});
// Handle email composition
sendEmailBtn.addEventListener('click', function() {
if (!mappedColumns) {
showError("Veuillez d'abord mapper la colonne e-mail.");
return;
}
const activeEmails = emailList.filter(email => !removedEmails.has(email));
if (activeEmails.length === 0) {
showError("Aucun e-mail de destinataire trouvé");
return;
}
const replyTo = replyToInput.value.trim();
if (!replyTo || !isValidEmail(replyTo)) {
showError("Veuillez saisir une adresse e-mail de réponse valide");
return;
}
const subject = subjectInput.value.trim();
if (!subject) {
showError("Veuillez saisir un objet d'e-mail");
return;
}
const content = emailContent.value.trim();
if (!content) {
showError("Veuillez saisir le contenu de l'e-mail");
return;
}
// Construct mailto URL with BCC
const mailtoUrl = `mailto:${replyTo}?bcc=${activeEmails.join(',')}&subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(content)}`;
// Open default email client
window.location.href = mailtoUrl;
showSuccess("Votre client de messagerie par défaut s'est bien ouvert");
});
Merci encore
2 « J'aime »
Hello @Amandine
je me suis permis de forker ton repo ici : GitHub - gristgouv/mae-custom-widgets: Grist custom widgets built by the French Ministry of Foreign Affairs
Est-ce-que ça te dit que je t’ajoute au dépot et que tu puisses le maintenir dans l’orga « gristgouv » avec nous ?
Ton widget est servi depuis : https://gristgouv.github.io/mae-custom-widgets/batch-emailing/index.html , plus besoin du custom widget builder pour le mettre dans un doc
Je vais l’ajouter à notre bibliothèque de widget
1 « J'aime »
Oh wow,
Yes bien sur !
Du coup, si ça rentre dans la bibliothèque de l’instance DINUM, je suis chaud aussi de le basculer sur la version DSFR (que j’ai gardé pour nous, j’ai publié qu’une version ‹ grand public ›)
Yoann
Septembre 11, 2025, 2:31
7
Bonjour Amandine,
J’ai utilisé la version sur l’instance DINUM.
La liste des emails ne change que si on met un filtre dans la vue du widget, pas dans la table d’origine, c’est normal ?
Et malgré le filtre sur la vue, pour le contenu du message il conserve tout le temps le contenu du premier enregistrement, le filtre n’a pas d’effet. Sauf si je vais sur une autre page et que je reviens sur la page avec le filre actif, là il prend bien le premier enregistrement du filtre pour le contenu du mail, c’est voulu ce comportement ?
Bonjour,
Merci pour ce super widget ! Petite question, est-il possible d’envisager avoir une partie du corps de mail qui soit automatiquement pré-rempli par rapport aux informations d’une colonne et si oui, avez vous une idée de la méthode à employer ?
Merci d’avance
Chez moi, seule la version de la DINUM fonctionne.
Dans la version d’origine, le bouton « compose email » ne déclenche rien.
Par ailleurs, serait-il possible de remplacer les virgules par des points virgules entre les destinataires ? Ca ne plaît pas à mon client mail.
Comme amélioration, je pensais à des champs reply-to, cc et sujet directement récupéré dans la table, ce qui permettrait des les générer dynamiquement.
Merci pour tout le boulot.
Bonjour,
merci pour ce widget qui me rend énormément service.
Cependant, lorsque je suis en mobilité sur iPad , le bouton « Compose Email » ne déclenche rien (juste le message disant « Email composer opened in your default email client »… mais le client mail ne s’ouvre pas, et il n’y a pas de message prégénéré.
En revanche, lorsque je génère un lien dans une colonne avec « mailto: » cela ouvre bien mon client mail.
Java est bien activé sur mon navigateur.
Merci beaucoup pour le dépannage !
Enro
Janvier 20, 2026, 7:56
11
J’ai demandé à l’IA et il semble que c’est une restriction de sécurité d’iOS : ChatGPT - Vérification champ vide
Je serais bien incapable de coder une version qui marche sur iOS. Le conseil est donc de passer par un ordinateur de bureau pour composer vos emails.