/** * Studio Pro By Charlot · réception des demandes de devis. * * La page /pages/cadeau-entreprise envoie ici chaque demande du questionnaire. * Pour chacune, ce script : * 1. écarte les robots (champ piège, remplissage trop rapide) et les doublons * (double clic, nouvel essai après une coupure réseau) ; * 2. range les pièces jointes dans un dossier Drive par demande ; * 3. ajoute une ligne à la feuille « Demandes Studio Pro », qui sert de suivi ; * 4. envoie la demande complète à business@, « Répondre » allant au client ; * 5. envoie au client un accusé de réception. * * L'accusé de réception ne reprend QUE des choix cliqués, jamais un texte libre : * sinon n'importe qui pourrait se servir du formulaire pour faire envoyer, depuis * business@, le texte de son choix à l'adresse de son choix. * * Déploiement : Déployer > Nouveau déploiement > Application Web, * « Exécuter en tant que : moi », « Qui a accès : Tout le monde ». * Avant tout, lancer une fois testerReception() depuis l'éditeur. * * Les mails partent de business@bycharlot.com. Si le script appartient à un autre * compte (cs@ par exemple), business@ doit être une adresse d'envoi de ce compte : * Gmail > Paramètres > Comptes > « Envoyer des e-mails en tant que ». * testerReception() le vérifie et le dit clairement. */ const DESTINATAIRE = "business@bycharlot.com"; const EXPEDITEUR = "business@bycharlot.com"; const DOSSIER_RACINE = "Studio Pro · Demandes de devis"; const NOM_FEUILLE = "Demandes Studio Pro"; const OCTETS_MAX = 25 * 1024 * 1024; // toutes pièces jointes décodées const OCTETS_MAIL = 20 * 1024 * 1024; // au-delà, le mail donne le lien du dossier au lieu des pièces const LIMITE_PAR_EMAIL = 3; // demandes par adresse et par heure const LIMITE_GLOBALE = 40; // demandes par heure, toutes adresses const DUREE_MIN = 8; // secondes passées sur le formulaire, en dessous c'est un robot // Les seules valeurs qu'on accepte de reprendre dans l'accusé de réception const CHOIX = { occasion: ["Fin d'année", "Remercier des clients", "Collaborateurs", "Événement", "Décorer vos bureaux"], quantite: ["10 à 25", "25 à 50", "50 à 100", "100 à 300", "Plus de 300", "Je ne sais pas encore"], plantesMode: ["Une même plante pour tous", "Un mix de plantes", "Conseillez-moi"], potsMode: ["Une même couleur pour tous", "Un mix de couleurs", "Je vous laisse choisir"], livraisonMode: ["Tout à une adresse", "Chez chaque destinataire", "Sur quelques sites"], budget: ["Moins de 30 €", "30 à 50 €", "50 à 80 €", "Plus de 80 €", "À définir ensemble"], lieu: ["Bureaux", "Hôtel", "Restaurant ou bar", "Boutique", "Espace d'accueil", "Autre"], interieur: ["Intérieur", "Extérieur", "Les deux"], entretien: ["Oui, l'entretien nous intéresse", "Non, nous nous en occupons", "À voir ensemble"], budgetAmenagement: ["Moins de 2 000 €", "2 000 à 5 000 €", "5 000 à 15 000 €", "Plus de 15 000 €", "À définir ensemble"], echeance: ["Dès que possible", "Dans les trois mois", "Cette année", "Pas encore décidé"] }; const LIB_OCCASION = { "Collaborateurs": "Accueillir ou remercier des collaborateurs", "Événement": "Un événement, un séminaire" }; const LIB_ETIQUETTE = { "logo-titre": "Logo et titre", "logo-texte": "Logo et petit texte", "logo": "Logo seul", "texte": "Texte seul", "aucune": "Sans personnalisation" }; const LIB_TAILLES = { XS: "Petites", S: "Moyennes", M: "Grandes" }; const TYPES = { logo: /^image\/(png|jpeg)$/, apercu: /^image\/jpeg$/, destinataires: /^application\/vnd\.openxmlformats-officedocument\.spreadsheetml\.sheet$/, origine: /^(application\/vnd\.openxmlformats-officedocument\.spreadsheetml\.sheet|application\/vnd\.ms-excel|text\/csv|text\/plain|application\/vnd\.oasis\.opendocument\.spreadsheet|application\/octet-stream|)$/, documents: /^(image\/(png|jpeg|webp|heic)|application\/pdf)$/ }; function doGet() { return repondre({ ok: true, service: "studio-pro" }); } function doPost(e) { try { const d = JSON.parse(e.postData.contents); return repondre(traiter(d)); } catch (err) { console.error("Studio Pro · erreur : " + (err && err.stack || err)); return repondre({ ok: false, erreur: "technique" }); } } function repondre(o) { return ContentService.createTextOutput(JSON.stringify(o)).setMimeType(ContentService.MimeType.JSON); } // ------------------------------------------------------------------ le traitement function traiter(d, essai) { // 1. robots : on leur répond « ok » pour ne rien leur apprendre if (d.siteWeb) return { ok: true }; if (!(Number(d.duree) >= DUREE_MIN)) return { ok: true }; const id = String(d.id || ""); if (!/^[a-z0-9-]{8,64}$/i.test(id)) return { ok: false, erreur: "demande" }; const c = d.contact || {}; const email = propre(c.email, 120).toLowerCase(); const societe = propre(c.societe, 120), nom = propre(c.nom, 120); if (!societe || !nom || !/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(email)) return { ok: false, erreur: "contact" }; // 2. doublons et limites, sous verrou : deux envois simultanés ne passent pas tous les deux const cache = CacheService.getScriptCache(); const verrou = LockService.getScriptLock(); verrou.waitLock(20000); try { if (cache.get("id:" + id)) return { ok: true, doublon: true }; const nEmail = Number(cache.get("email:" + email) || 0), nTout = Number(cache.get("tout") || 0); if (!essai && (nEmail >= LIMITE_PAR_EMAIL || nTout >= LIMITE_GLOBALE)) return { ok: false, erreur: "limite" }; cache.put("email:" + email, String(nEmail + 1), 3600); cache.put("tout", String(nTout + 1), 3600); cache.put("id:" + id, "1", 21600); } finally { verrou.releaseLock(); } // 3. pièces jointes : type et taille contrôlés const pieces = []; let total = 0; (Array.isArray(d.fichiers) ? d.fichiers : []).slice(0, 6).forEach(f => { const role = String(f.role || ""); if (!TYPES[role] || !TYPES[role].test(String(f.type || ""))) return; const octets = Utilities.base64Decode(String(f.base64 || "")); total += octets.length; if (!octets.length || total > OCTETS_MAX) return; pieces.push({ role: role, blob: Utilities.newBlob(octets, f.type || "application/octet-stream", nomFichier(f.nom, role)) }); }); const r = d.reponses || {}; // un projet sur mesure (formulaire classique de la page) se reconnait tout de suite dans la boite const surMesure = d.type === "sur-mesure"; const amenagement = d.type === "amenagement"; const genre = amenagement ? "Aménagement d'espace" : (surMesure ? "Projet sur mesure" : "Devis Studio Pro"); const quantite = r.quantiteExacte ? `${entier(r.quantiteExacte)} cadeaux` : (CHOIX.quantite.includes(r.quantite) ? `${r.quantite} cadeaux` : "quantité à préciser"); const livraison = CHOIX.livraisonMode.includes(r.livraisonMode) ? r.livraisonMode : ""; const dateVoulue = /^\d{4}-\d{2}-\d{2}$/.test(r.date || "") && !r.pasDeDate ? r.date : ""; const quand = dateVoulue ? "avant le " + dateVoulue.split("-").reverse().join("/") : ""; // 4. le dossier Drive de la demande const jour = Utilities.formatDate(new Date(), "Europe/Paris", "yyyy-MM-dd HH'h'mm"); const coiffe = amenagement ? (valeur(r, "lieu") || "aménagement") : quantite; const dossier = dossierRacine().createFolder(`${jour} · ${societe} · ${coiffe}`); pieces.forEach(p => dossier.createFile(p.blob)); const resume = (Array.isArray(d.resume) ? d.resume : []).slice(0, 20) .map(l => [propre(l[0], 60), String(l[1] == null ? "" : l[1]).slice(0, 3000)]); dossier.createFile(`Demande ${societe}.txt`, resume.map(l => `${l[0]}\n${l[1]}\n`).join("\n"), MimeType.PLAIN_TEXT); // 5. la ligne de suivi feuille().appendRow([new Date(), societe, nom, email, propre(c.telephone, 40), valeur(r, "occasion"), quantite, valeur(r, "plantesMode"), valeur(r, "potsMode"), LIB_ETIQUETTE[r.etiquetteType] || (r.etiquetteType === "Autre" ? "Autre" : ""), livraison, dateVoulue, (surMesure || amenagement) ? genre : valeur(r, "budget"), propre(r.source, 60), dossier.getUrl(), "Nouvelle"]); // 6. le mail à l'équipe : tout, y compris les textes libres (il ne part qu'à business@) const objet = (amenagement ? [genre, societe, valeur(r, "lieu"), valeur(r, "echeance")] : [genre, societe, quantite, livraison, quand]).filter(String).join(" · ").replace(/[\r\n]+/g, " "); const poids = pieces.reduce((s, p) => s + p.blob.getBytes().length, 0); const joindre = poids <= OCTETS_MAIL; GmailApp.sendEmail(DESTINATAIRE, (essai ? "[TEST] " : "") + objet, texteBrut(resume), envoiDepuisBusiness({ htmlBody: mailEquipe(resume, dossier.getUrl(), pieces, joindre, essai), attachments: joindre ? pieces.map(p => p.blob) : [], replyTo: email, name: "Studio Pro By Charlot" })); // 7. l'accusé de réception : uniquement des choix cliqués const recap = amenagement ? recapAmenagement(r) : recapClient(r, dateVoulue); const sujetClient = amenagement ? "Votre projet d'aménagement By Charlot" : "Votre demande de devis By Charlot"; GmailApp.sendEmail(email, (essai ? "[TEST] " : "") + sujetClient, texteBrut(recap), envoiDepuisBusiness({ htmlBody: mailClient(prenomDe(nom), recap), replyTo: DESTINATAIRE, name: "By Charlot" })); return { ok: true }; } // ------------------------------------------------------------------ l'adresse d'envoi // Compte business@ : rien à faire. Autre compte : on envoie « en tant que » business@, // ce qui n'est possible que si business@ est déclarée comme adresse d'envoi de ce compte. function expediteurPossible() { const moi = String(Session.getEffectiveUser().getEmail() || "").toLowerCase(); if (moi === EXPEDITEUR) return "compte"; return GmailApp.getAliases().map(a => String(a).toLowerCase()).includes(EXPEDITEUR) ? "alias" : ""; } function envoiDepuisBusiness(o) { const mode = expediteurPossible(); if (mode === "alias") o.from = EXPEDITEUR; if (!mode) console.warn("Studio Pro : business@ n'est pas une adresse d'envoi de ce compte, le mail part de " + Session.getEffectiveUser().getEmail()); return o; } // ------------------------------------------------------------------ outils function propre(v, max) { return String(v == null ? "" : v).replace(/[\u0000-\u001f]/g, " ").replace(/\s+/g, " ").trim().slice(0, max || 200); } function entier(v) { const n = parseInt(String(v), 10); return n > 0 && n < 100000 ? n : ""; } function esc(s) { return String(s).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } function valeur(r, champ) { return CHOIX[champ].includes(r[champ]) ? r[champ] : (r[champ] === "Autre" ? "Autre" : ""); } function prenomDe(nom) { const p = propre(nom, 60).split(" ")[0] || ""; return /^[\p{L}'-]{2,30}$/u.test(p) ? p : ""; } function nomFichier(nom, role) { const n = propre(nom, 80).replace(/[\\/:*?"<>|]/g, "-"); const prefixe = { logo: "Logo", apercu: "Aperçu étiquette", destinataires: "Destinataires vérifiés", origine: "Fichier d'origine" }[role]; return n ? `${prefixe} · ${n}` : prefixe; } function dossierRacine() { const it = DriveApp.getFoldersByName(DOSSIER_RACINE); return it.hasNext() ? it.next() : DriveApp.createFolder(DOSSIER_RACINE); } function feuille() { const props = PropertiesService.getScriptProperties(); let id = props.getProperty("feuille"); if (id) { try { return SpreadsheetApp.openById(id).getSheets()[0]; } catch (e) { id = null; } } const ss = SpreadsheetApp.create(NOM_FEUILLE); const f = ss.getSheets()[0]; f.appendRow(["Reçue le", "Société", "Contact", "Email", "Téléphone", "Occasion", "Quantité", "Plantes", "Pots", "Étiquette", "Livraison", "Date souhaitée", "Type ou budget", "Nous a connus par", "Dossier", "Statut"]); f.setFrozenRows(1); f.getRange(1, 1, 1, 16).setFontWeight("bold"); DriveApp.getFileById(ss.getId()).moveTo(dossierRacine()); props.setProperty("feuille", ss.getId()); return f; } function texteBrut(lignes) { return lignes.map(l => `${l[0]} : ${l[1]}`).join("\n"); } // ce que le client a cliqué, et seulement cela function recapClient(r, dateVoulue) { const l = []; if (r.occasion) l.push(["Occasion", r.occasion === "Autre" ? "Autre, précisée dans votre demande" : (LIB_OCCASION[r.occasion] || valeur(r, "occasion"))]); const q = entier(r.quantiteExacte); if (q || CHOIX.quantite.includes(r.quantite)) l.push(["Nombre de cadeaux", q ? `${q}` : r.quantite]); const tailles = (Array.isArray(r.tailles) ? r.tailles : []).map(t => LIB_TAILLES[t]).filter(String); const plantes = [valeur(r, "plantesMode"), tailles.length ? `tailles : ${tailles.join(", ").toLowerCase()}` : ""].filter(String).join(", "); if (plantes) l.push(["Plantes", plantes]); if (valeur(r, "potsMode")) l.push(["Pots", valeur(r, "potsMode")]); if (LIB_ETIQUETTE[r.etiquetteType]) l.push(["Étiquette", LIB_ETIQUETTE[r.etiquetteType]]); if (valeur(r, "livraisonMode")) l.push(["Livraison", valeur(r, "livraisonMode") + (dateVoulue ? `, souhaitée avant le ${dateVoulue.split("-").reverse().join("/")}` : "")]); if (valeur(r, "budget")) l.push(["Budget par cadeau HT", valeur(r, "budget")]); return l; } function recapAmenagement(r) { const l = []; if (valeur(r, "lieu")) l.push(["Type de lieu", valeur(r, "lieu")]); if (propre(r.surface, 80)) l.push(["Surface ou nombre d'espaces", propre(r.surface, 80)]); if (valeur(r, "interieur")) l.push(["Intérieur ou extérieur", valeur(r, "interieur")]); if (valeur(r, "entretien")) l.push(["Entretien", valeur(r, "entretien")]); if (CHOIX.budgetAmenagement.includes(r.budgetAmenagement)) l.push(["Budget indicatif", r.budgetAmenagement]); if (valeur(r, "echeance")) l.push(["Échéance", valeur(r, "echeance")]); return l; } // ------------------------------------------------------------------ les mails const STYLE_TD = "padding:8px 10px;border-bottom:1px solid #e3ded4;vertical-align:top;font-size:14px;"; function tableau(lignes) { return `
| ${esc(l[0])} | ${l[1] ? esc(l[1]) : "·"} |
Demande de test, envoyée depuis l\'éditeur du script.
' : ""}Nouvelle demande de devis reçue depuis la page Studio Pro.
${tableau(resume)}${joindre ? (pj.length ? "Pièces jointes : " + esc(pj.join(", ")) + "." : "Aucune pièce jointe.") : "Pièces jointes trop lourdes pour un mail : elles sont dans le dossier."}
Ouvrir le dossier de la demande
« Répondre » écrit directement au client.
Bonjour${prenom ? " " + esc(prenom) : ""},
Merci pour votre demande, nous l'avons bien reçue. Nous revenons vers vous sous 24 à 48 h ouvrées avec votre devis et le bon à tirer de votre étiquette.
${lignes.length ? "Voici ce que vous nous avez indiqué :
" + tableau(lignes) : ""}Un détail à ajouter ou à corriger ? Répondez simplement à cet email.
À très vite,
L'équipe By Charlot