feat: add contacts phase 2, advanced search, vacation responder, Docker & TOTP 2FA

- Contact groups/lists, vCard import/export (RFC 6350), bulk operations
- Advanced search with JMAP filter panel, search chips, cross-mailbox queries
- Vacation responder with JMAP VacationResponse, settings tab, sidebar indicator
- TOTP two-factor authentication support
- Docker multi-stage build with standalone output and docker-compose
- CSP Report-Only headers and security headers via proxy middleware
- Virtual scrolling for large email lists
- Structured server-side logger (text/JSON, configurable level)
- 450+ tests (contacts, vCard, threads, headers, identity, components)
- Playwright E2E framework setup
- Updated README and ROADMAP with all new features
This commit is contained in:
Matthieu MALVACHE
2026-02-16 18:51:30 +01:00
committed by Matthieu MALVACHE
parent 8a5bc9b88d
commit fb414bf2c9
70 changed files with 8160 additions and 547 deletions
+133 -5
View File
@@ -11,14 +11,19 @@
"error": {
"invalid_credentials": "Email ou mot de passe invalide",
"connection_failed": "Échec de la connexion au serveur",
"generic": "Une erreur s'est produite. Veuillez réessayer."
"generic": "Une erreur s'est produite. Veuillez réessayer.",
"totp_invalid": "Code d'authentification invalide. Vérifiez votre application d'authentification."
},
"config_error": {
"title": "Erreur de configuration",
"fetch_failed": "Impossible de charger la configuration de l'application. Veuillez réessayer plus tard.",
"server_not_configured": "Le serveur de messagerie n'a pas été configuré. Veuillez contacter votre administrateur."
},
"remove_from_history": "Supprimer de l'historique"
"remove_from_history": "Supprimer de l'historique",
"totp_toggle": "J'ai l'authentification à deux facteurs",
"totp_label": "Code d'authentification",
"totp_placeholder": "000000",
"totp_hide": "Masquer l'authentification à deux facteurs"
},
"sidebar": {
"close": "Fermer",
@@ -59,7 +64,8 @@
"compose": "Composer",
"go_back": "Retour"
},
"clear_search": "Effacer la recherche"
"clear_search": "Effacer la recherche",
"vacation_active": "Répondeur d'absence activé"
},
"email_list": {
"no_emails": "Aucun email",
@@ -313,7 +319,9 @@
"identity_update_failed": "Échec de la mise à jour de l'identité: {{error}}",
"identity_delete_failed": "Échec de la suppression de l'identité: {{error}}",
"identity_unauthorized": "Vous n'êtes pas autorisé à envoyer depuis cette adresse email",
"identity_not_found": "Identité introuvable"
"identity_not_found": "Identité introuvable",
"vacation_saved": "Paramètres du répondeur d'absence enregistrés",
"vacation_save_failed": "Échec de l'enregistrement des paramètres du répondeur d'absence"
},
"date": {
"today": "Aujourd'hui",
@@ -366,6 +374,7 @@
"privacy": "Confidentialité et sécurité",
"account": "Compte",
"identities": "Identités",
"vacation": "Répondeur d'absence",
"advanced": "Avancé"
},
"appearance": {
@@ -568,6 +577,49 @@
"learn_more": "En savoir plus"
}
},
"vacation": {
"title": "Répondeur d'absence",
"description": "Répondre automatiquement aux emails entrants pendant votre absence",
"loading": "Chargement des paramètres d'absence...",
"not_supported": "Votre serveur de messagerie ne prend pas en charge les réponses d'absence.",
"fetch_error": "Échec du chargement des paramètres d'absence. Veuillez réessayer.",
"status": {
"label": "Répondeur d'absence",
"description": "Envoyer une réponse automatique aux personnes qui vous envoient un email",
"active": "Actif",
"inactive": "Inactif"
},
"date_range": {
"title": "Période",
"description": "Limiter optionnellement la réponse automatique à une période spécifique",
"start": "Date de début",
"start_description": "Laisser vide pour aucune limite de début",
"end": "Date de fin",
"end_description": "Laisser vide pour aucune limite de fin"
},
"message": {
"title": "Message de réponse automatique",
"description": "Le message qui sera envoyé en réponse",
"subject_label": "Objet",
"subject_description": "Ligne d'objet de la réponse automatique",
"subject_placeholder": "Absence du bureau",
"body_label": "Corps du message",
"body_description": "Contenu du message en texte brut",
"body_placeholder": "Merci pour votre email. Je suis actuellement absent du bureau et vous répondrai à mon retour."
},
"preview": {
"title": "Aperçu",
"show": "Afficher l'aperçu",
"hide": "Masquer l'aperçu"
},
"save": "Enregistrer les modifications",
"saving": "Enregistrement...",
"warnings": {
"end_before_start": "La date de fin doit être postérieure à la date de début",
"start_in_past": "La date de début est dans le passé",
"empty_body": "Le corps du message est vide — les destinataires recevront une réponse vide"
}
},
"advanced": {
"title": "Avancé",
"description": "Options avancées et paramètres développeur",
@@ -753,6 +805,10 @@
"delete_confirm": "Êtes-vous sûr de vouloir supprimer ce contact ?",
"local_mode": "Les contacts sont stockés localement (le serveur ne prend pas en charge JMAP Contacts)",
"back_to_mail": "Retour aux e-mails",
"tabs": {
"all": "Tous",
"groups": "Groupes"
},
"detail": {
"emails": "Adresses e-mail",
"phones": "Numéros de téléphone",
@@ -788,6 +844,55 @@
"email_invalid": "Veuillez saisir une adresse e-mail valide",
"save_failed": "Échec de l'enregistrement du contact"
},
"groups": {
"create": "Nouveau groupe",
"edit": "Modifier le groupe",
"empty": "Aucun groupe",
"delete_confirm": "Êtes-vous sûr de vouloir supprimer ce groupe ?",
"name_label": "Nom du groupe",
"name_placeholder": "ex. Équipe, Famille",
"name_required": "Le nom du groupe est requis",
"save_failed": "Échec de l'enregistrement du groupe",
"members_label": "Membres",
"search_members": "Rechercher des contacts à ajouter...",
"no_members": "Aucun membre dans ce groupe",
"member_count": "{count, plural, =0 {Aucun membre} one {1 membre} other {# membres}}"
},
"import": {
"title": "Importer des contacts",
"drop_hint": "Cliquez pour sélectionner un fichier vCard",
"file_types": "Fichiers .vcf ou .vcard",
"no_contacts": "Aucun contact trouvé dans le fichier",
"parse_error": "Échec de l'analyse du fichier vCard",
"found": "{count, plural, one {1 contact trouvé} other {# contacts trouvés}}",
"duplicate": "Doublon",
"select_all": "Tout sélectionner",
"deselect_all": "Tout désélectionner",
"selected": "{count, plural, one {1 sélectionné} other {# sélectionnés}}",
"import_button": "Importer",
"importing": "Importation...",
"success": "{count, plural, one {1 contact importé} other {# contacts importés}}",
"failed": "Échec de l'importation",
"close": "Fermer",
"file_too_large": "Fichier trop volumineux (max 5 Mo)"
},
"export": {
"title": "Exporter les contacts",
"success": "{count, plural, one {1 contact exporté} other {# contacts exportés}}"
},
"bulk": {
"selected": "{count, plural, one {1 sélectionné} other {# sélectionnés}}",
"select_all": "Tout sélectionner",
"delete": "Supprimer",
"delete_confirm": "Supprimer {count, plural, one {1 contact} other {# contacts}} ?",
"deleted": "{count, plural, one {1 contact supprimé} other {# contacts supprimés}}",
"add_to_group": "Ajouter au groupe",
"choose_group": "Choisir un groupe",
"adding_contacts": "Ajout de {count, plural, one {1 contact} other {# contacts}}",
"added_to_group": "Contacts ajoutés au groupe",
"export": "Exporter",
"clear": "Effacer la sélection"
},
"toast": {
"created": "Contact créé",
"updated": "Contact mis à jour",
@@ -796,5 +901,28 @@
"error_update": "Échec de la mise à jour du contact",
"error_delete": "Échec de la suppression du contact"
}
},
"advanced_search": {
"title": "Recherche avancée",
"from": "De",
"from_placeholder": "Email ou nom de l'expéditeur",
"to": "À",
"to_placeholder": "Email ou nom du destinataire",
"subject": "Objet",
"subject_placeholder": "L'objet contient...",
"body": "Corps",
"has_attachment": "Pièces jointes",
"date_after": "Après le",
"date_before": "Avant le",
"starred": "Suivis",
"unread": "Non lus",
"read": "Lus",
"yes": "Oui",
"no": "Non",
"clear": "Effacer",
"clear_all": "Tout effacer",
"filters_active": "{count} filtre",
"filters_active_plural": "{count} filtres",
"toggle_filters": "Filtres"
}
}
}