<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Fernando Peralta</title>
    <description>The latest articles on DEV Community by Fernando Peralta (@fernandoperaltacarlos).</description>
    <link>https://dev.to/fernandoperaltacarlos</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F788325%2F30fab1e8-6186-4f72-b2b2-4566239bc57c.jpeg</url>
      <title>DEV Community: Fernando Peralta</title>
      <link>https://dev.to/fernandoperaltacarlos</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/fernandoperaltacarlos"/>
    <language>en</language>
    <item>
      <title>Cómo crear un plugin de WordPress: PHP, WooCommerce y Elementor</title>
      <dc:creator>Fernando Peralta</dc:creator>
      <pubDate>Tue, 13 Oct 2026 22:21:00 +0000</pubDate>
      <link>https://dev.to/fernandoperaltacarlos/como-crear-un-plugin-de-wordpress-php-woocommerce-y-elementor-efl</link>
      <guid>https://dev.to/fernandoperaltacarlos/como-crear-un-plugin-de-wordpress-php-woocommerce-y-elementor-efl</guid>
      <description>&lt;p&gt;&lt;em&gt;Publicado originalmente en &lt;a href="https://fernandoperalta.xyz/es/blog/crear-plugin-wordpress-woocommerce-elementor/" rel="noopener noreferrer"&gt;mi blog: Cómo crear un plugin de WordPress con PHP, WooCommerce y Elementor&lt;/a&gt;. Esta es la versión adaptada para DEV Community.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Mucha personalización de WordPress termina en &lt;code&gt;functions.php&lt;/code&gt;. Funciona hasta que cambia el tema y la funcionalidad desaparece con él. Todo lo que debe sobrevivir al tema, como una regla de envío, una regla de precios o un widget propio, pertenece a un &lt;strong&gt;plugin&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Esta guía construye un plugin pequeño como estructuro el trabajo de clientes: un archivo de entrada limpio, hooks en lugar de ediciones, seguridad por defecto, una regla de WooCommerce y un widget de Elementor. La parte de WooCommerce refleja el tipo de trabajo que hice en Industria Social para &lt;a href="https://fernandoperalta.xyz/es/casos-de-estudio/plugins-woocommerce-envios-bundles-personalizados/" rel="noopener noreferrer"&gt;GroovePro&lt;/a&gt;, donde las reglas de envío y los precios de bundles se mantuvieron en dos plugins PHP separados.&lt;/p&gt;

&lt;p&gt;Los ejemplos usan un plugin inventado llamado &lt;strong&gt;Acme Shipping Rules&lt;/strong&gt;. Cambia el prefijo (&lt;code&gt;acme_sr_&lt;/code&gt;) por el tuyo.&lt;/p&gt;

&lt;p&gt;Crea &lt;code&gt;wp-content/plugins/acme-shipping-rules/acme-shipping-rules.php&lt;/code&gt; como archivo principal y añade ahí los hooks y funciones de los pasos 2 a 5. El widget va en &lt;code&gt;widgets/class-acme-savings-widget.php&lt;/code&gt;; la limpieza va en &lt;code&gt;uninstall.php&lt;/code&gt;. Los bloques que continúan el archivo principal no necesitan otra apertura &lt;code&gt;&amp;lt;?php&lt;/code&gt;. La consulta SQL del paso 3 es un ejemplo independiente, no parte del plugin de envíos.&lt;/p&gt;

&lt;h2&gt;
  
  
  Paso 1: el archivo del plugin y su encabezado
&lt;/h2&gt;

&lt;p&gt;Un plugin es una carpeta en &lt;code&gt;wp-content/plugins/&lt;/code&gt; con un archivo PHP principal. Solo &lt;code&gt;Plugin Name&lt;/code&gt; es obligatorio en el encabezado, pero un plugin real debería declarar más, según los &lt;a href="https://developer.wordpress.org/plugins/plugin-basics/header-requirements/" rel="noopener noreferrer"&gt;requisitos del encabezado de plugins de WordPress&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;
&lt;span class="cd"&gt;/**
 * Plugin Name:       Acme Shipping Rules
 * Description:       Ajusta las tarifas de envío de WooCommerce según la cantidad por paquete.
 * Version:           1.0.0
 * Requires at least: 6.5
 * Requires PHP:      8.3
 * Author:            Fernando Peralta
 * License:           GPL v2 or later
 * Text Domain:       acme-shipping-rules
 * Requires Plugins:  woocommerce
 */&lt;/span&gt;

&lt;span class="c1"&gt;// Bloquea el acceso directo al archivo.&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;defined&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'ABSPATH'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Requires Plugins&lt;/code&gt; permite que WordPress se niegue a activar tu plugin si falta WooCommerce (WordPress 6.5 y posteriores). La guarda &lt;code&gt;ABSPATH&lt;/code&gt; evita que alguien cargue el archivo fuera de WordPress.&lt;/p&gt;

&lt;p&gt;Este ejemplo declara WordPress 6.5 como mínimo para usar &lt;code&gt;Requires Plugins&lt;/code&gt; y PHP 8.3 como base. La versión de WooCommerce instalada puede exigir una versión de WordPress superior; comprueba sus &lt;a href="https://woocommerce.com/document/server-requirements/" rel="noopener noreferrer"&gt;requisitos de entorno&lt;/a&gt;. Elementor es opcional: sin él, el plugin mantiene la regla de envío y no registra el widget.&lt;/p&gt;

&lt;p&gt;Dos hábitos que previenen la mayoría de las colisiones entre plugins:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Prefija todo&lt;/strong&gt;: funciones, opciones, hooks y nombres de clases (&lt;code&gt;acme_sr_&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Usa un text domain&lt;/strong&gt; y envuelve los textos en &lt;code&gt;__()&lt;/code&gt; o &lt;code&gt;esc_html__()&lt;/code&gt; desde el primer día, para que traducir después no sea una refactorización.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Paso 2: hooks, no ediciones
&lt;/h2&gt;

&lt;p&gt;WordPress es dirigido por eventos. Nunca editas el núcleo ni otro plugin: te enganchas a &lt;strong&gt;actions&lt;/strong&gt; (hacer algo en un momento) y &lt;strong&gt;filters&lt;/strong&gt; (cambiar un valor mientras pasa).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Ejecuta algo cuando se activa el plugin.&lt;/span&gt;
&lt;span class="nf"&gt;register_activation_hook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;__FILE__&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'acme_sr_activate'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;acme_sr_activate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;add_option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'acme_sr_threshold'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;register_activation_hook()&lt;/code&gt; solo se dispara al activar, por lo que es el lugar correcto para crear opciones por defecto. Deja ahí cualquier preparación más pesada, como crear tablas, en lugar de hacerla en cada petición.&lt;/p&gt;

&lt;h2&gt;
  
  
  Paso 3: la seguridad no es opcional
&lt;/h2&gt;

&lt;p&gt;El manejador comprueba permisos, verifica el nonce y &lt;strong&gt;valida un entero mayor o igual a 1 en el servidor&lt;/strong&gt;. &lt;code&gt;min="1"&lt;/code&gt; ayuda en el navegador, pero no sustituye esta validación. Los arrays, campos ausentes, texto, decimales y valores menores que 1 se rechazan sin cambiar la opción:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'admin_post_acme_sr_save'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'acme_sr_save_settings'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;acme_sr_save_settings&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nf"&gt;current_user_can&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'manage_woocommerce'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;wp_die&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;esc_html__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'No tienes permiso.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'acme-shipping-rules'&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Verifica el token contra CSRF; no sustituye los permisos.&lt;/span&gt;
    &lt;span class="nf"&gt;check_admin_referer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'acme_sr_save'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Valida el tipo y el rango antes de guardar.&lt;/span&gt;
    &lt;span class="nv"&gt;$raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$_POST&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'threshold'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;is_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$_POST&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'threshold'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;wp_unslash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$_POST&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'threshold'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nv"&gt;$threshold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;filter_var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;FILTER_VALIDATE_INT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'options'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="s1"&gt;'min_range'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$threshold&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;wp_die&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;esc_html__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'La cantidad mínima debe ser un entero mayor o igual a 1.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'acme-shipping-rules'&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nf"&gt;update_option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'acme_sr_threshold'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$threshold&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;wp_safe_redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;admin_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'admin.php?page=acme-shipping-rules'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Registra una página en &lt;strong&gt;WooCommerce → Reglas de envío&lt;/strong&gt; mediante &lt;code&gt;admin_menu&lt;/code&gt; y &lt;code&gt;add_submenu_page()&lt;/code&gt;. Este bloque pertenece al archivo principal e incluye la función que muestra el formulario:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;add_action( 'admin_menu', 'acme_sr_register_settings_page' );

function acme_sr_register_settings_page() {
    add_submenu_page(
        'woocommerce',
        esc_html__( 'Reglas de envío', 'acme-shipping-rules' ),
        esc_html__( 'Reglas de envío', 'acme-shipping-rules' ),
        'manage_woocommerce',
        'acme-shipping-rules',
        'acme_sr_render_settings_page'
    );
}

function acme_sr_render_settings_page() {
    if ( ! current_user_can( 'manage_woocommerce' ) ) {
        wp_die( esc_html__( 'No tienes permiso.', 'acme-shipping-rules' ), 403 );
    }
    ?&amp;gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"wrap"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;h1&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;?php esc_html_e( 'Reglas de envío', 'acme-shipping-rules' ); ?&amp;gt;&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;form&lt;/span&gt; &lt;span class="na"&gt;method=&lt;/span&gt;&lt;span class="s"&gt;"post"&lt;/span&gt; &lt;span class="na"&gt;action=&lt;/span&gt;&lt;span class="s"&gt;"&amp;lt;?php echo esc_url( admin_url( 'admin-post.php' ) ); ?&amp;gt;"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"hidden"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"action"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"acme_sr_save"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="cp"&gt;&amp;lt;?php wp_nonce_field( 'acme_sr_save' ); ?&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;label&lt;/span&gt; &lt;span class="na"&gt;for=&lt;/span&gt;&lt;span class="s"&gt;"threshold"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;?php esc_html_e( 'Cantidad mínima por paquete', 'acme-shipping-rules' ); ?&amp;gt;&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/label&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"threshold"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"threshold"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"number"&lt;/span&gt; &lt;span class="na"&gt;min=&lt;/span&gt;&lt;span class="s"&gt;"1"&lt;/span&gt; &lt;span class="na"&gt;step=&lt;/span&gt;&lt;span class="s"&gt;"1"&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;
                &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"&amp;lt;?php echo esc_attr( get_option( 'acme_sr_threshold', 3 ) ); ?&amp;gt;"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"submit"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;?php esc_html_e( 'Guardar', 'acme-shipping-rules' ); ?&amp;gt;&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/form&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notas que vale la pena recordar:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Un nonce ayuda a proteger contra CSRF; no demuestra por sí solo el origen de una petición ni concede permisos. Usa también &lt;code&gt;current_user_can()&lt;/code&gt;, como explica la &lt;a href="https://developer.wordpress.org/apis/security/nonces/" rel="noopener noreferrer"&gt;documentación de nonces de WordPress&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Escapa en el momento de la salida: &lt;code&gt;esc_html()&lt;/code&gt;, &lt;code&gt;esc_attr()&lt;/code&gt;, &lt;code&gt;esc_url()&lt;/code&gt;. Sanear al entrar no vuelve segura la salida.&lt;/li&gt;
&lt;li&gt;Cualquier SQL propio pasa por &lt;code&gt;$wpdb-&amp;gt;prepare()&lt;/code&gt;:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;global&lt;/span&gt; &lt;span class="nv"&gt;$wpdb&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$table&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$wpdb&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'acme_sr_log'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$wpdb&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get_results&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nv"&gt;$wpdb&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s2"&gt;"SELECT * FROM &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; WHERE order_id = %d"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$order_id&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Paso 4: una regla de envío en WooCommerce
&lt;/h2&gt;

&lt;p&gt;El filtro &lt;code&gt;woocommerce_package_rates&lt;/code&gt; recibe las tarifas y &lt;strong&gt;un paquete de envío&lt;/strong&gt;. Este ejemplo descuenta 20 % del costo y sus impuestos cuando ese paquete contiene la cantidad mínima. Si el carrito se divide en varios paquetes, cada uno se evalúa por separado; no se suman todos los productos del carrito.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Incluye la regla en el hash del paquete para evitar tarifas antiguas.&lt;/span&gt;
&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'woocommerce_cart_shipping_packages'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'acme_sr_shipping_rule_cache_key'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;acme_sr_shipping_rule_cache_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$packages&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$threshold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;get_option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'acme_sr_threshold'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$packages&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$package&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$packages&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="s1"&gt;'acme_sr_threshold'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$threshold&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$packages&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'woocommerce_package_rates'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'acme_sr_adjust_rates'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;acme_sr_adjust_rates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$rates&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$package&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$quantity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$package&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'contents'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$item&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$quantity&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'quantity'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$quantity&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nb"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;get_option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'acme_sr_threshold'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$rates&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$rates&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$rate&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// 20% de descuento si este paquete alcanza la cantidad mínima.&lt;/span&gt;
        &lt;span class="nv"&gt;$rate&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;set_cost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$rate&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get_cost&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// Ajusta los impuestos proporcionalmente al costo descontado.&lt;/span&gt;
        &lt;span class="nv"&gt;$taxes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;array_map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$tax&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$tax&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nv"&gt;$rate&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get_taxes&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$rate&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;set_taxes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$taxes&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$rates&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;$rate&lt;/code&gt; es un &lt;code&gt;WC_Shipping_Rate&lt;/code&gt;, que expone &lt;code&gt;get_cost()&lt;/code&gt; y &lt;code&gt;set_cost()&lt;/code&gt;, además de &lt;code&gt;get_taxes()&lt;/code&gt; y &lt;code&gt;set_taxes()&lt;/code&gt;. Dos advertencias prácticas:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;La caché debe reflejar la regla.&lt;/strong&gt; &lt;code&gt;acme_sr_shipping_rule_cache_key()&lt;/code&gt; añade la cantidad mínima a cada paquete antes de calcular las tarifas. Al guardar otra cantidad, cambia el hash y WooCommerce recalcula en la siguiente solicitud del cliente. El valor es estable: no invalida la caché en cada visita. El &lt;a href="https://woocommerce.github.io/code-reference/files/woocommerce-includes-class-wc-shipping.html" rel="noopener noreferrer"&gt;código de envíos de WooCommerce&lt;/a&gt; muestra cómo el paquete participa en el hash.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prueba con impuestos activados y desactivados&lt;/strong&gt;, con varias zonas de envío y con cupones que otorgan envío gratis.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Declara compatibilidad con HPOS
&lt;/h3&gt;

&lt;p&gt;El High-Performance Order Storage de WooCommerce cambia dónde viven los pedidos. Un plugin que no declara compatibilidad no aparece como compatible en los ajustes de funciones de WooCommerce, por lo que debes revisar su código antes de declarar compatibilidad desde el archivo principal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'before_woocommerce_init'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nb"&gt;class_exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nc"&gt;\Automattic\WooCommerce\Utilities\FeaturesUtil&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;\Automattic\WooCommerce\Utilities\FeaturesUtil&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;declare_compatibility&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s1"&gt;'custom_order_tables'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="k"&gt;__FILE__&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Declararlo es una promesa: lee y escribe pedidos con los métodos CRUD de WooCommerce (&lt;code&gt;wc_get_order()&lt;/code&gt;, &lt;code&gt;$order-&amp;gt;get_meta()&lt;/code&gt;), no con consultas directas a &lt;code&gt;wp_postmeta&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Paso 5: un widget de Elementor
&lt;/h2&gt;

&lt;p&gt;En sitios construidos con Elementor, el cliente suele querer un bloque que pueda soltar en cualquier página. Un widget es una clase PHP que extiende &lt;code&gt;\Elementor\Widget_Base&lt;/code&gt;, siguiendo la &lt;a href="https://developers.elementor.com/docs/widgets/widget-structure" rel="noopener noreferrer"&gt;estructura de widgets de Elementor&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;
&lt;span class="c1"&gt;// widgets/class-acme-savings-widget.php&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;defined&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'ABSPATH'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Acme_Savings_Widget&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;\Elementor\Widget_Base&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;get_name&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'acme_savings'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;get_title&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;esc_html__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'Ahorro en envío'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'acme-shipping-rules'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;get_icon&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'eicon-code'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;get_categories&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="s1"&gt;'general'&lt;/span&gt; &lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;register_controls&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;start_controls_section&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'content_section'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'label'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;esc_html__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'Contenido'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'acme-shipping-rules'&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="s1"&gt;'tab'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;\Elementor\Controls_Manager&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;TAB_CONTENT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;add_control&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'message'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'label'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;esc_html__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'Mensaje'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'acme-shipping-rules'&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="s1"&gt;'type'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;\Elementor\Controls_Manager&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'default'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;esc_html__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'El descuento de envío se calcula por paquete.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'acme-shipping-rules'&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;end_controls_section&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;render&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$settings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get_settings_for_display&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;p class="acme-savings"&amp;gt;'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nf"&gt;esc_html&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$settings&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'message'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;/p&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Regístralo desde el archivo principal del plugin. El archivo de la clase se incluye &lt;strong&gt;dentro&lt;/strong&gt; del hook, porque las clases de Elementor no existen hasta que Elementor se cargó:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'elementor/widgets/register'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$widgets_manager&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;require_once&lt;/span&gt; &lt;span class="k"&gt;__DIR__&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/widgets/class-acme-savings-widget.php'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$widgets_manager&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;\Acme_Savings_Widget&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El widget ya aparece en el panel de Elementor, su control &lt;code&gt;message&lt;/code&gt; es editable por el cliente y la salida está escapada.&lt;/p&gt;

&lt;p&gt;El widget es informativo: su mensaje se edita en Elementor y no determina la elegibilidad para el descuento. La regla se aplica al calcular el envío para cada paquete.&lt;/p&gt;

&lt;h2&gt;
  
  
  Paso 6: rendimiento y mantenimiento
&lt;/h2&gt;

&lt;p&gt;Un plugin se ejecuta en cada petición, así que cuida lo que cargas:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Carga assets solo donde se necesitan.&lt;/strong&gt; Encola scripts y estilos dentro de &lt;code&gt;wp_enqueue_scripts&lt;/code&gt; y protégelos con condicionales como &lt;code&gt;is_cart()&lt;/code&gt; o &lt;code&gt;is_checkout()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No consultes en cada página.&lt;/strong&gt; Guarda en caché los resultados costosos con transients e invalídalos cuando los datos cambien.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Limpia al desinstalar.&lt;/strong&gt; Elimina solo datos propiedad del plugin; no borres pedidos ni datos de otros plugins.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Versiona tus assets&lt;/strong&gt; con la versión del plugin para que los navegadores no sirvan archivos viejos después de un lanzamiento.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Para este ejemplo, &lt;code&gt;uninstall.php&lt;/code&gt; elimina únicamente la opción del plugin al desinstalarlo. Usa &lt;code&gt;WP_UNINSTALL_PLUGIN&lt;/code&gt; como guarda en ese archivo; la desactivación no debe borrar la configuración:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;
&lt;span class="c1"&gt;// uninstall.php&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;defined&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'WP_UNINSTALL_PLUGIN'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;delete_option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'acme_sr_threshold'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Antes de entregar, pasa el código por los &lt;a href="https://github.com/WordPress/WordPress-Coding-Standards" rel="noopener noreferrer"&gt;WordPress Coding Standards&lt;/a&gt; con PHP_CodeSniffer y por el plugin oficial Plugin Check, que señala problemas de seguridad y compatibilidad.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lista previa al lanzamiento
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Encabezado completo, guarda &lt;code&gt;ABSPATH&lt;/code&gt; en el principal y el widget, y &lt;code&gt;WP_UNINSTALL_PLUGIN&lt;/code&gt; en &lt;code&gt;uninstall.php&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Todas las funciones, opciones y clases con prefijo.&lt;/li&gt;
&lt;li&gt;Permisos, nonce y validación del entero mínimo en el servidor; salida escapada en el formulario y el widget.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;$wpdb-&amp;gt;prepare()&lt;/code&gt; en cualquier SQL con variables.&lt;/li&gt;
&lt;li&gt;HPOS declarado y pedidos leídos con las APIs de WooCommerce.&lt;/li&gt;
&lt;li&gt;Assets encolados solo donde se necesitan.&lt;/li&gt;
&lt;li&gt;Pruebas con cantidades bajo/en/sobre el mínimo, varios paquetes, impuestos, zonas, envío gratis y cambio de mínimo en una sesión con tarifas en caché.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;uninstall.php&lt;/code&gt; y changelog listos.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Cuándo construir en lugar de comprar
&lt;/h2&gt;

&lt;p&gt;Un plugin ya hecho es la decisión correcta cuando hace exactamente lo que necesitas y tiene mantenimiento activo. Un plugin a medida se justifica cuando la regla es específica de tu negocio, como una fórmula de envío o un precio por bundle, o cuando de otro modo apilarías tres plugins para aproximarla.&lt;/p&gt;

&lt;p&gt;Este es el trabajo que hago como &lt;a href="https://fernandoperalta.xyz/es/desarrollador-wordpress/" rel="noopener noreferrer"&gt;desarrollador WordPress en CDMX&lt;/a&gt;. Puedes ver proyectos relacionados en los &lt;a href="https://fernandoperalta.xyz/es/casos-de-estudio/" rel="noopener noreferrer"&gt;casos de estudio&lt;/a&gt;, como el &lt;a href="https://fernandoperalta.xyz/es/casos-de-estudio/desarrollo-sitio-web-bilingue-wordpress-elementor/" rel="noopener noreferrer"&gt;sitio bilingüe en WordPress y Elementor para Mezcal Viviente&lt;/a&gt;. Si necesitas un plugin a medida, &lt;a href="mailto:hola@fernandoperalta.xyz"&gt;escríbeme&lt;/a&gt; con la regla que quieres automatizar.&lt;/p&gt;

&lt;h3&gt;
  
  
  Recurso adicional: desplegar WordPress en Azure
&lt;/h3&gt;

&lt;p&gt;Si además de desarrollar plugins quieres explorar opciones de despliegue, Microsoft ofrece documentación para alojar WordPress mediante Azure App Service y Azure Database for MySQL.&lt;/p&gt;

&lt;p&gt;Puedes consultar la &lt;a href="https://learn.microsoft.com/azure/app-service/quickstart-wordpress?wt.mc_id=studentamb_641347" rel="noopener noreferrer"&gt;guía oficial para implementar WordPress en Azure&lt;/a&gt;, que explica cómo configurar y administrar una instalación de WordPress en la nube.&lt;/p&gt;




&lt;h3&gt;
  
  
  Sobre mí
&lt;/h3&gt;

&lt;p&gt;Soy Fernando Peralta, desarrollador web especializado en proyectos WordPress, WooCommerce y desarrollo a medida. Comparto artículos técnicos y proyectos en &lt;a href="https://fernandoperalta.xyz/es/" rel="noopener noreferrer"&gt;mi sitio web&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;También trabajo en &lt;a href="https://devstoremx.xyz/" rel="noopener noreferrer"&gt;Devstoremx&lt;/a&gt;, mi agencia de desarrollo web en México, donde desarrollamos sitios web, tiendas en línea, aplicaciones e integraciones a medida.&lt;/p&gt;

&lt;p&gt;Puedes consultar &lt;a href="https://fernandoperalta.xyz/es/blog/crear-plugin-wordpress-woocommerce-elementor/" rel="noopener noreferrer"&gt;la guía original en mi blog&lt;/a&gt; y explorar otros casos reales de desarrollo WordPress.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>woocommerce</category>
      <category>elementor</category>
      <category>php</category>
    </item>
    <item>
      <title>How I freed more than 12GB in my Hotmail account with Jev (and skipped yet another subscription)</title>
      <dc:creator>Fernando Peralta</dc:creator>
      <pubDate>Tue, 13 Oct 2026 21:23:00 +0000</pubDate>
      <link>https://dev.to/fernandoperaltacarlos/how-i-freed-more-than-12gb-in-my-hotmail-account-with-jev-and-skipped-yet-another-subscription-1d17</link>
      <guid>https://dev.to/fernandoperaltacarlos/how-i-freed-more-than-12gb-in-my-hotmail-account-with-jev-and-skipped-yet-another-subscription-1d17</guid>
      <description>&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://fernandoperalta.xyz/blog/como-libere-12gb-hotmail-jev/" rel="noopener noreferrer"&gt;my personal development blog: How I freed more than 12GB in my Hotmail account with Jev&lt;/a&gt;. This is the adapted version for DEV Community.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In an era of endless subscriptions — streaming, cloud storage, productivity tools — hitting a storage limit often means yet another monthly fee. I had that problem with my Hotmail account: it was out of storage and held about &lt;strong&gt;70,000 emails&lt;/strong&gt;. Some were promotions, newsletters, automated notices, and messages that no longer mattered. Others were receipts, login details, and conversations I still needed. Deleting everything by sender or by age was too risky. Reading every message by hand would never end. Instead of paying for more storage, I decided to reclaim space.&lt;/p&gt;

&lt;p&gt;That is why I built &lt;strong&gt;Mail Organizer&lt;/strong&gt;: a local app for deciding which emails I could delete, reviewing the uncertain cases, and running the deletion from Outlook. What turned a blind cleanup into a useful review was &lt;strong&gt;&lt;a href="https://docs.typesafe.ai/introduction" rel="noopener noreferrer"&gt;Jev&lt;/a&gt;&lt;/strong&gt;, the System One model from &lt;a href="https://typesafe.ai/" rel="noopener noreferrer"&gt;TypeSafe&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://fernandoperalta.xyz/blog/como-libere-12gb-hotmail-jev/" rel="noopener noreferrer"&gt;View the original results illustration: 62,025 emails deleted and 8,058 messages remaining&lt;/a&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The goal was not for an AI to empty my inbox.&lt;/strong&gt; It was to turn thousands of repetitive decisions into a list I could understand and approve.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The stack I chose
&lt;/h2&gt;

&lt;p&gt;I wanted the app to run on my computer and for secrets never to reach the browser. I chose a single &lt;strong&gt;Next.js 15&lt;/strong&gt; project with &lt;strong&gt;React 19&lt;/strong&gt;, &lt;strong&gt;TypeScript&lt;/strong&gt;, and &lt;strong&gt;Tailwind CSS&lt;/strong&gt; for the interface; its API routes run the server logic. That let me build the review panel and the cleanup jobs in the same application, without deploying a separate service.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Piece&lt;/th&gt;
&lt;th&gt;Technology&lt;/th&gt;
&lt;th&gt;Its job in the project&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Interface and server&lt;/td&gt;
&lt;td&gt;Next.js App Router, React, TypeScript, and Tailwind&lt;/td&gt;
&lt;td&gt;Show the guided flow and run the API routes in Node.js.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mail access&lt;/td&gt;
&lt;td&gt;Microsoft Graph and &lt;code&gt;@azure/msal-node&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Sign in, query messages, and request deletion in Outlook.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI judgments&lt;/td&gt;
&lt;td&gt;Jev through &lt;code&gt;@typesafe-ai/sdk&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Evaluate each email and return structured probabilities.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Persistence&lt;/td&gt;
&lt;td&gt;SQLite with &lt;code&gt;better-sqlite3&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Store metadata, judgments, in-progress jobs, and the deletion history.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The important split is inside that one process: the browser displays data and collects my decisions; the API routes hold the Microsoft session and the TypeSafe key, call external services, and write to SQLite. Secrets live in &lt;code&gt;.env.local&lt;/code&gt; and are read only on the server.&lt;/p&gt;

&lt;p&gt;I also orchestrated agents in &lt;strong&gt;Cursor&lt;/strong&gt; and &lt;strong&gt;OpenCode&lt;/strong&gt; with the models &lt;strong&gt;GPT-6 Astra&lt;/strong&gt;, &lt;strong&gt;GPT-6 Sol&lt;/strong&gt;, &lt;strong&gt;Opus 5.5&lt;/strong&gt;, &lt;strong&gt;Grok 4.7&lt;/strong&gt;, and &lt;strong&gt;Muse Spark 1.3&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  From a full inbox to a list I could review
&lt;/h2&gt;

&lt;p&gt;I split the work into date ranges. The app connects to Outlook through Microsoft Graph and stores in a local SQLite database only what it needs to classify a message: sender, subject, a short preview, date, folder, and a few signals such as whether the email has attachments. It does not download or store the full body or the attachments. When I want to read a message, the app asks Outlook for the body at that moment.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;React panel:&lt;/strong&gt; sends actions to the Next.js API routes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API routes:&lt;/strong&gt; use MSAL and the local session to talk to Microsoft Graph.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Microsoft Graph:&lt;/strong&gt; returns date-range metadata; the API stores it in SQLite.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SQLite → Jev:&lt;/strong&gt; each query sends one email; Jev returns typed judgments that go back into the database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Protection policy:&lt;/strong&gt; filters the lists I see in the panel. On confirmation, the API asks Graph for a permanent delete.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Saving those judgments in SQLite mattered. I could stop and resume without classifying the same messages twice. I could also change the decision thresholds and rebuild the lists without asking Jev again.&lt;/p&gt;

&lt;h3&gt;
  
  
  Authentication and sync
&lt;/h3&gt;

&lt;p&gt;For a personal Hotmail account I used &lt;strong&gt;OAuth with MSAL&lt;/strong&gt;, a public client, and the &lt;strong&gt;Authorization Code + PKCE&lt;/strong&gt; flow. The app requests delegated mail permissions, checks that the connected account is the one I configured, and keeps the session on the server. It never asks for my password and never uses a &lt;em&gt;client secret&lt;/em&gt; in the browser. That logic lives in &lt;code&gt;auth.ts&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Every sync requires dates. Internally I convert the inclusive dates from the UI into a &lt;code&gt;[from, to)&lt;/code&gt; interval and query Microsoft Graph page by page. I store a cursor and progress in SQLite so I can continue if a session stops. When I sync again, I update the metadata without erasing the fact that Jev already classified a message. That sits in &lt;code&gt;sync.ts&lt;/code&gt; and &lt;code&gt;messages.ts&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;SQLite has another job: Outlook does not know my &lt;code&gt;junk_probability&lt;/code&gt; field, so I could not filter or group emails by that value directly in Graph. In the local database I separated &lt;code&gt;messages&lt;/code&gt; (metadata), &lt;code&gt;judgments&lt;/code&gt; (Jev answers), &lt;code&gt;scores&lt;/code&gt; (policy result), &lt;code&gt;jobs&lt;/code&gt; (progress), and &lt;code&gt;deletion_log&lt;/code&gt; (audit). If I change a threshold, I recompute &lt;code&gt;scores&lt;/code&gt; from &lt;code&gt;judgments&lt;/code&gt; without paying for another classification.&lt;/p&gt;

&lt;h3&gt;
  
  
  Long-running jobs and a UI that explains progress
&lt;/h3&gt;

&lt;p&gt;Syncing, classifying, and deleting thousands of emails takes longer than a normal web request. The API routes start &lt;strong&gt;background jobs inside the local process&lt;/strong&gt; and return control to the panel. &lt;code&gt;jobs&lt;/code&gt; stores the range, cursor, and counters; each classified email is persisted as soon as it finishes. If the server restarts, the app recognizes the interrupted job and I can continue without starting over.&lt;/p&gt;

&lt;p&gt;I organized the interface into five steps — connect, choose dates, sync, classify, and review — and added an activity log for pages received, errors, and retries. That solved a design problem as important as the classification itself: during a long operation I needed to know whether the system was advancing, waiting on a quota, or waiting for me.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I asked Jev
&lt;/h2&gt;

&lt;p&gt;A rule like “if it says &lt;em&gt;newsletter&lt;/em&gt;, delete it” cannot tell useless advertising from an email I still need. Jev therefore scores &lt;strong&gt;each message on its own&lt;/strong&gt;, with ten structured questions. The main one is, in essence: &lt;em&gt;“Could I permanently delete this email without losing anything I still need?”&lt;/em&gt; The answer is a &lt;strong&gt;Noul&lt;/strong&gt;, TypeSafe’s &lt;a href="https://docs.typesafe.ai/primitives" rel="noopener noreferrer"&gt;yes-or-no question&lt;/a&gt;, which returns a probability between 0 and 1. That probability feeds the list of disposable messages.&lt;/p&gt;

&lt;p&gt;This fragment from the classifier (&lt;code&gt;classify.ts&lt;/code&gt;) shows the core idea: I send one email’s context together with the ten questions and read a typed response, instead of parsing free text.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;typeSafe&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;systemOne&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;stateFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;folders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;evaluatedAt&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;jev-latest&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MAIL_QUESTIONS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;probability&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;answers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;is_disposable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;noul&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The other questions add context and protection: whether it looks like marketing or an automated notice, whether a person is waiting for a reply, whether it contains credentials, whether it is a financial or legal record, and whether its usefulness has already expired. For time-sensitive cases, Jev receives both the email date and the evaluation date. A temporary code from years ago and an invoice from the same day should not be treated the same way.&lt;/p&gt;

&lt;p&gt;The app makes &lt;strong&gt;one Jev request per email&lt;/strong&gt;, but includes all ten questions in that same request. Nine are Noul questions and the tenth scores how painful it would be to lose the message on a five-level scale. I capped classification at 15 concurrent requests and 900 starts per minute; transient errors retry, and messages that keep failing stay marked so I can process them again. Patterns like &lt;code&gt;no-reply&lt;/code&gt; only move a message earlier in the queue: they never declare it junk on their own.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Judgment&lt;/th&gt;
&lt;th&gt;What I did with it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;75% or higher&lt;/strong&gt; chance it was disposable&lt;/td&gt;
&lt;td&gt;It showed up as &lt;strong&gt;deletable&lt;/strong&gt;, ready for my review.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;40% up to, but not including, 75%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;It went to &lt;strong&gt;review&lt;/strong&gt;: the decision needed more context.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Under 40%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;It stayed in &lt;strong&gt;keep&lt;/strong&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That percentage is the model’s judgment, not a guarantee that deletion is safe. I added separate brakes: an email with strong signs of credentials, financial or legal documents, or a personal conversation moves to &lt;strong&gt;keep&lt;/strong&gt; even if Jev calls it disposable. I can also protect senders with an allowlist. Before deleting a large batch, the app can label a sample of up to one hundred emails and compare my decisions with Jev’s, so I can catch false positives.&lt;/p&gt;

&lt;p&gt;In code, the policy (&lt;code&gt;policy.ts&lt;/code&gt;) looks roughly like this (shortened):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;probability&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;judgment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;is_disposable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;protectedMail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;judgment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;contains_access_credentials&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.60&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
  &lt;span class="nx"&gt;judgment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;contains_financial_or_legal&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.60&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
  &lt;span class="nx"&gt;judgment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;is_addressed_personally&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.70&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
  &lt;span class="nx"&gt;senderRule&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;always_keep&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bucket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;protectedMail&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;keep&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;probability&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.75&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;deletable&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;probability&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.40&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;review&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;keep&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That split was deliberate: Jev provides the judgments; a deterministic function applies my thresholds and protections. I can change the policy without changing the model answers, and I can explain why each email landed in each list.&lt;/p&gt;

&lt;h2&gt;
  
  
  The step that actually freed space
&lt;/h2&gt;

&lt;p&gt;Classification does not recover storage by itself. I inspected the lists first, grouped emails by sender, and reviewed the uncertain messages. To delete, the app shows the protection blocks and requires me to type the exact number of selected emails. Only then does it send Microsoft Graph a &lt;strong&gt;permanent delete&lt;/strong&gt;. Moving messages to &lt;em&gt;Deleted Items&lt;/em&gt; was not enough for the quota problem.&lt;/p&gt;

&lt;p&gt;If you want to build something similar, the key operation is &lt;code&gt;permanentDelete&lt;/code&gt;: Microsoft Graph exposes it through &lt;code&gt;POST /me/messages/{id}/permanentDelete&lt;/code&gt; and, for a personal account, requires the delegated &lt;code&gt;Mail.ReadWrite&lt;/code&gt; permission. The documentation explains that the message moves to the &lt;em&gt;Purges&lt;/em&gt; folder under &lt;em&gt;Recoverable Items&lt;/em&gt;. That does not mean it is physically erased immediately: retention policies may still apply.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Official Microsoft Graph documentation:&lt;/strong&gt; check the permissions, endpoint, and what happens in the Purges folder in the &lt;a href="https://learn.microsoft.com/graph/api/message-permanentdelete?view=graph-rest-1.0&amp;amp;wt.mc_id=studentamb_641347" rel="noopener noreferrer"&gt;Microsoft Learn permanentDelete guide&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Before calling Graph I write a &lt;code&gt;pending&lt;/code&gt; row for each message in &lt;code&gt;deletion_log&lt;/code&gt;. Then I send &lt;code&gt;permanentDelete&lt;/code&gt; in batches of up to 20 operations, using immutable identifiers so a folder move does not change the reference to the email. Only a successful response marks the record as &lt;code&gt;deleted&lt;/code&gt;; a &lt;code&gt;404&lt;/code&gt; or an uncertain response keeps the case for review or retry. That flow lives in &lt;code&gt;deletion.ts&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Another limit showed up during the bulk cleanup: Microsoft throttles requests. Deletion slowed down once I hit that quota. I added a preventive budget of 9,000 operations per ten minutes and a shared wait when Microsoft responds with &lt;code&gt;Retry-After&lt;/code&gt;. The process retries only the items that failed, without repeating deletions already confirmed. The panel shows when it is waiting on the quota, and the log keeps each email’s result; a network error is not reported as a successful delete.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Recorded result:&lt;/strong&gt; on September 22, 2026, the local history marked &lt;strong&gt;62,025 distinct emails as deleted&lt;/strong&gt; and &lt;strong&gt;zero pending or failed&lt;/strong&gt;. The local database still held &lt;strong&gt;8,058 messages&lt;/strong&gt;. Jev used &lt;strong&gt;100,737,845 tokens&lt;/strong&gt;, at a cost of &lt;strong&gt;$3.6393&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;What started as an inbox I could not sort became a manageable flow: limit the dates, ask Jev for clear judgments, protect what matters, review, and confirm the deletion. Jev saved me the repetitive classification. The rules and the human review kept the final decision in my hands.&lt;/p&gt;

&lt;h3&gt;
  
  
  Further reading
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://docs.typesafe.ai/introduction" rel="noopener noreferrer"&gt;What Jev is and how it works&lt;/a&gt;, in the official TypeSafe docs.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.typesafe.ai/primitives" rel="noopener noreferrer"&gt;Noul, Score, and Choice&lt;/a&gt;, the structured answers I used to build the classification.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://api.typesafe.ai/docs" rel="noopener noreferrer"&gt;System One API reference&lt;/a&gt;, for the query contract.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://learn.microsoft.com/graph/api/message-permanentdelete?view=graph-rest-1.0&amp;amp;wt.mc_id=studentamb_641347" rel="noopener noreferrer"&gt;Permanently deleting messages (Microsoft Graph)&lt;/a&gt;, official guide with the permissions and the Purges folder.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  About me
&lt;/h3&gt;

&lt;p&gt;I'm Fernando Peralta, a web developer working on software development, web applications, and technology projects.&lt;/p&gt;

&lt;p&gt;I'm also involved in &lt;a href="https://devstoremx.xyz/" rel="noopener noreferrer"&gt;Devstoremx&lt;/a&gt;, my &lt;strong&gt;web development agency in Mexico&lt;/strong&gt;, where we work on websites, custom applications, e-commerce solutions, integrations, and automation.&lt;/p&gt;

&lt;p&gt;I share personal projects, technical experiences, and development articles on &lt;a href="https://fernandoperalta.xyz/" rel="noopener noreferrer"&gt;my personal website and software development blog&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you'd like to explore this project with its original design and illustrations, you can &lt;a href="https://fernandoperalta.xyz/blog/como-libere-12gb-hotmail-jev/" rel="noopener noreferrer"&gt;read the complete article on my blog&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>ai</category>
      <category>sqlite</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Cómo liberé más de 12GB de espacio en mi cuenta de Hotmail con Jev (Ahorrándome una suscripción más)</title>
      <dc:creator>Fernando Peralta</dc:creator>
      <pubDate>Tue, 13 Oct 2026 20:56:00 +0000</pubDate>
      <link>https://dev.to/fernandoperaltacarlos/como-libere-mas-de-12gb-de-espacio-en-mi-cuenta-de-hotmail-con-jev-ahorrandome-una-suscripcion-mas-c12</link>
      <guid>https://dev.to/fernandoperaltacarlos/como-libere-mas-de-12gb-de-espacio-en-mi-cuenta-de-hotmail-con-jev-ahorrandome-una-suscripcion-mas-c12</guid>
      <description>&lt;p&gt;&lt;em&gt;Este artículo se publicó originalmente en &lt;a href="https://fernandoperalta.xyz/es/blog/como-libere-12gb-hotmail-jev/" rel="noopener noreferrer"&gt;mi blog de desarrollo web: cómo liberé más de 12 GB de Hotmail con Jev&lt;/a&gt;. Esta es su versión adaptada para DEV Community.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;En una época en la que acumulamos miles de suscripciones —streaming, almacenamiento en la nube, herramientas de productividad— casi cualquier límite de espacio termina convirtiéndose en otra cuota mensual. Yo tuve este problema con mi cuenta de Hotmail: estaba al límite de almacenamiento y acumulaba alrededor de &lt;strong&gt;70 mil correos&lt;/strong&gt;. Entre ellos había promociones, boletines, avisos automáticos y mensajes que ya habían perdido su utilidad. También había recibos, accesos y conversaciones que sí necesitaba conservar. Borrar todo por remitente o por antigüedad era demasiado arriesgado; revisar cada mensaje a mano, interminable. En lugar de pagar más almacenamiento, decidí recuperar espacio.&lt;/p&gt;

&lt;p&gt;Así nació &lt;strong&gt;Mail Organizer&lt;/strong&gt;: una aplicación local para decidir qué correos podía eliminar, revisar los casos dudosos y ejecutar el borrado desde Outlook. La pieza que me permitió pasar de una limpieza a ciegas a una revisión útil fue &lt;strong&gt;&lt;a href="https://docs.typesafe.ai/introduction" rel="noopener noreferrer"&gt;Jev&lt;/a&gt;&lt;/strong&gt;, el modelo System One de &lt;a href="https://typesafe.ai/" rel="noopener noreferrer"&gt;TypeSafe&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://fernandoperalta.xyz/es/blog/como-libere-12gb-hotmail-jev/" rel="noopener noreferrer"&gt;Ver la ilustración original del resultado: 62 025 correos borrados y 8 058 mensajes restantes&lt;/a&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;El objetivo no era que una IA vaciara mi buzón.&lt;/strong&gt; Era reducir miles de decisiones repetitivas a una lista que yo pudiera entender y aprobar.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Las tecnologías que elegí
&lt;/h2&gt;

&lt;p&gt;Quería que la aplicación corriera en mi computadora y que las claves nunca llegaran al navegador. Elegí un solo proyecto &lt;strong&gt;Next.js 15&lt;/strong&gt; con &lt;strong&gt;React 19&lt;/strong&gt;, &lt;strong&gt;TypeScript&lt;/strong&gt; y &lt;strong&gt;Tailwind CSS&lt;/strong&gt; para la interfaz; sus rutas API ejecutan la lógica del servidor. Así podía construir el panel de revisión y los procesos de limpieza en la misma aplicación, sin desplegar un servicio adicional.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pieza&lt;/th&gt;
&lt;th&gt;Tecnología&lt;/th&gt;
&lt;th&gt;Su trabajo en el proyecto&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Interfaz y servidor&lt;/td&gt;
&lt;td&gt;Next.js App Router, React, TypeScript y Tailwind&lt;/td&gt;
&lt;td&gt;Mostrar el flujo guiado y ejecutar las rutas API en Node.js.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Acceso al correo&lt;/td&gt;
&lt;td&gt;Microsoft Graph y &lt;code&gt;@azure/msal-node&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Iniciar sesión, consultar mensajes y solicitar el borrado en Outlook.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Juicios de IA&lt;/td&gt;
&lt;td&gt;Jev mediante &lt;code&gt;@typesafe-ai/sdk&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Evaluar cada correo y devolver probabilidades estructuradas.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Persistencia&lt;/td&gt;
&lt;td&gt;SQLite con &lt;code&gt;better-sqlite3&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Guardar metadatos, juicios, trabajos en curso y el historial de borrados.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;La separación importante está dentro de ese único proceso: el navegador muestra datos y recoge mis decisiones; las rutas API guardan la sesión de Microsoft y la clave de TypeSafe, consultan los servicios externos y escriben en SQLite. Los secretos viven en &lt;code&gt;.env.local&lt;/code&gt; y solo se leen del lado del servidor.&lt;/p&gt;

&lt;p&gt;También orquesté agentes en &lt;strong&gt;Cursor&lt;/strong&gt; y &lt;strong&gt;OpenCode&lt;/strong&gt; con los modelos &lt;strong&gt;GPT-6 Astra&lt;/strong&gt;, &lt;strong&gt;GPT-6 Sol&lt;/strong&gt;, &lt;strong&gt;Opus 5.5&lt;/strong&gt;, &lt;strong&gt;Grok 4.7&lt;/strong&gt; y &lt;strong&gt;Muse Spark 1.3&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Del buzón saturado a una lista revisable
&lt;/h2&gt;

&lt;p&gt;Dividí el trabajo por rangos de fechas. La aplicación se conecta a Outlook mediante Microsoft Graph y trae a una base SQLite local solo los datos necesarios para clasificar: remitente, asunto, una vista previa breve, fecha, carpeta y algunas señales como si el correo tiene adjuntos. No descarga ni guarda el cuerpo completo o los adjuntos. Si quiero leer un mensaje, la aplicación pide su cuerpo a Outlook en ese momento.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Panel React:&lt;/strong&gt; envía acciones a las rutas API de Next.js.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rutas API:&lt;/strong&gt; usan MSAL y la sesión local para hablar con Microsoft Graph.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Microsoft Graph:&lt;/strong&gt; devuelve metadatos por rango; la API los guarda en SQLite.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SQLite → Jev:&lt;/strong&gt; cada consulta envía un correo; Jev responde con juicios tipados que vuelven a la base.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Política de protección:&lt;/strong&gt; filtra las listas que veo en el panel. Al confirmar, la API pide el borrado permanente a Graph.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Guardar los juicios en SQLite fue importante. Pude detener y retomar el proceso sin clasificar dos veces los mismos mensajes. También pude ajustar los umbrales de decisión y recalcular las listas sin volver a consultar a Jev.&lt;/p&gt;

&lt;h3&gt;
  
  
  Autenticación y sincronización
&lt;/h3&gt;

&lt;p&gt;Para una cuenta personal de Hotmail usé &lt;strong&gt;OAuth con MSAL&lt;/strong&gt;, un cliente público y el flujo &lt;strong&gt;Authorization Code + PKCE&lt;/strong&gt;. La aplicación solicita permisos delegados de correo, comprueba que la cuenta conectada sea la que configuré y mantiene la sesión del lado del servidor. No pide mi contraseña ni usa un &lt;em&gt;client secret&lt;/em&gt; en el navegador. Esa lógica vive en &lt;code&gt;auth.ts&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Cada sincronización tiene fechas obligatorias. Internamente convierto las fechas inclusivas de la interfaz en un intervalo &lt;code&gt;[desde, hasta)&lt;/code&gt; y consulto Microsoft Graph página por página. Guardo un cursor y el progreso en SQLite para continuar si una sesión se interrumpe. Al volver a sincronizar, actualizo los metadatos sin borrar el hecho de que Jev ya clasificó un mensaje. Eso está en &lt;code&gt;sync.ts&lt;/code&gt; y &lt;code&gt;messages.ts&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;SQLite cumple otra función: Outlook no conoce mi campo &lt;code&gt;junk_probability&lt;/code&gt;, así que no podría filtrar o agrupar los correos por ese valor directamente en Graph. En la base local separé &lt;code&gt;messages&lt;/code&gt; (metadatos), &lt;code&gt;judgments&lt;/code&gt; (respuestas de Jev), &lt;code&gt;scores&lt;/code&gt; (resultado de la política), &lt;code&gt;jobs&lt;/code&gt; (progreso) y &lt;code&gt;deletion_log&lt;/code&gt; (auditoría). Si cambio un umbral, recalculo &lt;code&gt;scores&lt;/code&gt; a partir de &lt;code&gt;judgments&lt;/code&gt; sin pagar otra clasificación.&lt;/p&gt;

&lt;h3&gt;
  
  
  Trabajos largos y una interfaz que explica el progreso
&lt;/h3&gt;

&lt;p&gt;Sincronizar, clasificar y borrar miles de correos tarda más que una petición web normal. Las rutas API inician &lt;strong&gt;trabajos en segundo plano dentro del proceso local&lt;/strong&gt; y devuelven el control al panel. &lt;code&gt;jobs&lt;/code&gt; guarda el rango, el cursor y los contadores; cada correo clasificado se persiste en cuanto termina. Si el servidor se reinicia, la aplicación reconoce el trabajo interrumpido y puedo continuarlo sin empezar de cero.&lt;/p&gt;

&lt;p&gt;Organicé la interfaz en cinco pasos —conectar, elegir fechas, sincronizar, clasificar y revisar— y añadí un registro de actividad para páginas recibidas, errores y reintentos. Esto resolvió un problema de diseño igual de importante que la clasificación: durante una operación larga necesitaba saber si el sistema avanzaba, esperaba por una cuota o requería que interviniera.&lt;/p&gt;

&lt;h2&gt;
  
  
  Qué le pregunté a Jev
&lt;/h2&gt;

&lt;p&gt;Una regla como «si dice &lt;em&gt;newsletter&lt;/em&gt;, bórralo» no distingue entre publicidad inútil y un correo que todavía necesito. Por eso Jev evalúa &lt;strong&gt;cada mensaje por separado&lt;/strong&gt; con diez preguntas de respuesta estructurada. La principal es, en esencia: &lt;em&gt;«¿Podría eliminar este correo definitivamente sin perder nada que aún necesite?»&lt;/em&gt; Su respuesta es un &lt;strong&gt;Noul&lt;/strong&gt;, la &lt;a href="https://docs.typesafe.ai/primitives" rel="noopener noreferrer"&gt;pregunta de sí o no de TypeSafe&lt;/a&gt; que devuelve una probabilidad entre 0 y 1. Esa probabilidad alimenta la lista de mensajes prescindibles.&lt;/p&gt;

&lt;p&gt;Este fragmento del clasificador (&lt;code&gt;classify.ts&lt;/code&gt;) muestra la idea central: envío el contexto de un correo junto con las diez preguntas y leo una respuesta tipada, sin interpretar texto libre.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;typeSafe&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;systemOne&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;stateFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;folders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;evaluatedAt&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;jev-latest&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MAIL_QUESTIONS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;probability&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;answers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;is_disposable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;noul&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Las otras preguntas aportan contexto y protección: si parece marketing o un aviso automático, si una persona espera respuesta, si contiene credenciales, si es un comprobante financiero o legal, y si su utilidad ya caducó. Para los casos que dependen del tiempo, Jev recibe tanto la fecha del correo como la fecha de evaluación. Un código temporal de hace años y una factura del mismo día no deberían tratarse igual.&lt;/p&gt;

&lt;p&gt;La aplicación hace &lt;strong&gt;una consulta a Jev por correo&lt;/strong&gt;, pero incluye las diez preguntas en esa misma consulta. Nueve son Noul y la décima puntúa cuánto dolería perder el mensaje en una escala de cinco niveles. Limité la clasificación a 15 consultas simultáneas y 900 inicios por minuto; los errores transitorios se reintentan y los mensajes que siguen fallando quedan identificados para volver a procesarlos. Los patrones como &lt;code&gt;no-reply&lt;/code&gt; solo adelantan un correo en la cola: nunca lo declaran basura por sí mismos.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Resultado del juicio&lt;/th&gt;
&lt;th&gt;Qué hacía yo con él&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;75 % o más&lt;/strong&gt; de probabilidad de ser prescindible&lt;/td&gt;
&lt;td&gt;Aparecía como &lt;strong&gt;eliminable&lt;/strong&gt;, listo para mi revisión.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;40 % a menos de 75 %&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Iba a &lt;strong&gt;revisar&lt;/strong&gt;: la decisión necesitaba más contexto.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Menos de 40 %&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Quedaba en &lt;strong&gt;conservar&lt;/strong&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Ese porcentaje es un juicio del modelo, no una garantía de que borrar sea seguro. Por eso añadí frenos independientes: un correo con señales fuertes de credenciales, documentos financieros o legales, o una conversación personal pasa a &lt;strong&gt;conservar&lt;/strong&gt; aunque Jev lo considere prescindible. También puedo proteger remitentes con una lista blanca. Antes de borrar muchos mensajes, la aplicación permite etiquetar una muestra de hasta cien correos y comparar mis decisiones con las de Jev para detectar falsos positivos.&lt;/p&gt;

&lt;p&gt;En términos de código, la política (&lt;code&gt;policy.ts&lt;/code&gt;) se parece a esto (versión abreviada):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;probability&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;judgment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;is_disposable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;protectedMail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;judgment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;contains_access_credentials&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.60&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
  &lt;span class="nx"&gt;judgment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;contains_financial_or_legal&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.60&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
  &lt;span class="nx"&gt;judgment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;is_addressed_personally&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.70&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
  &lt;span class="nx"&gt;senderRule&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;always_keep&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bucket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;protectedMail&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;keep&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;probability&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.75&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;deletable&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;probability&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.40&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;review&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;keep&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Esta división fue deliberada: Jev aporta los juicios; una función determinista aplica mis umbrales y protecciones. Así puedo cambiar la política sin cambiar las respuestas del modelo, y puedo explicar por qué un correo quedó en cada lista.&lt;/p&gt;

&lt;h2&gt;
  
  
  El paso que realmente liberó espacio
&lt;/h2&gt;

&lt;p&gt;Clasificar no recupera almacenamiento por sí solo. Primero inspeccioné las listas, agrupé correos por remitente y revisé los mensajes dudosos. Para borrar, la aplicación muestra los bloqueos de protección y me exige escribir la cantidad exacta de correos seleccionados. Solo entonces envía a Microsoft Graph la operación de &lt;strong&gt;borrado permanente&lt;/strong&gt;. Mover mensajes a &lt;em&gt;Elementos eliminados&lt;/em&gt; no bastaba para el problema de cuota.&lt;/p&gt;

&lt;p&gt;Si quieres replicar algo parecido, la operación clave es &lt;code&gt;permanentDelete&lt;/code&gt;: Microsoft Graph la expone mediante &lt;code&gt;POST /me/messages/{id}/permanentDelete&lt;/code&gt; y, para una cuenta personal, requiere el permiso delegado &lt;code&gt;Mail.ReadWrite&lt;/code&gt;. La documentación explica que el mensaje pasa a la carpeta &lt;em&gt;Purges&lt;/em&gt; dentro de &lt;em&gt;Elementos recuperables&lt;/em&gt;. Esto no significa que desaparezca físicamente al instante: pueden aplicarse políticas de retención.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Documentación oficial de Microsoft Graph:&lt;/strong&gt; consulta los permisos, el endpoint y qué ocurre con la carpeta de purgas en la &lt;a href="https://learn.microsoft.com/graph/api/message-permanentdelete?view=graph-rest-1.0&amp;amp;wt.mc_id=studentamb_641347" rel="noopener noreferrer"&gt;guía de &lt;code&gt;permanentDelete&lt;/code&gt; de Microsoft Learn&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Antes de llamar a Graph escribo una fila &lt;code&gt;pending&lt;/code&gt; por cada mensaje en &lt;code&gt;deletion_log&lt;/code&gt;. Después envío &lt;code&gt;permanentDelete&lt;/code&gt; en lotes de hasta 20 operaciones, usando identificadores inmutables para que un movimiento entre carpetas no cambie la referencia al correo. Solo una respuesta exitosa marca el registro como &lt;code&gt;deleted&lt;/code&gt;; un &lt;code&gt;404&lt;/code&gt; o una respuesta incierta conserva el caso para revisión o reintento. Ese flujo está en &lt;code&gt;deletion.ts&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Durante la limpieza masiva apareció otro obstáculo: Microsoft limita la velocidad de las peticiones. El borrado se volvió lento cuando alcancé esa cuota. Incorporé un presupuesto preventivo de 9 000 operaciones por diez minutos y una espera compartida cuando Microsoft responde con &lt;code&gt;Retry-After&lt;/code&gt;. El proceso reenvía únicamente los elementos que fallaron, sin repetir los borrados ya confirmados. El panel muestra si está esperando por la cuota y el registro conserva el resultado de cada correo; un error de red no se presenta como un borrado exitoso.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Resultado registrado:&lt;/strong&gt; al 22 de septiembre de 2026, el historial local marcaba &lt;strong&gt;62 025 correos distintos como borrados&lt;/strong&gt; y &lt;strong&gt;cero pendientes o fallidos&lt;/strong&gt;. La base local conservaba &lt;strong&gt;8 058 mensajes&lt;/strong&gt;. Jev usó &lt;strong&gt;100 737 845 tokens&lt;/strong&gt;, con un costo de &lt;strong&gt;3.6393 dólares&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Lo que empezó como un buzón imposible de ordenar terminó en un flujo manejable: acotar fechas, pedir a Jev juicios claros, proteger lo valioso, revisar y confirmar el borrado. Jev me ahorró la clasificación repetitiva; las reglas y la revisión humana me ayudaron a conservar el control de la decisión final.&lt;/p&gt;

&lt;h3&gt;
  
  
  Para profundizar
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://docs.typesafe.ai/introduction" rel="noopener noreferrer"&gt;Qué es Jev y cómo funciona&lt;/a&gt;, en la documentación oficial de TypeSafe.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.typesafe.ai/primitives" rel="noopener noreferrer"&gt;Noul, Score y Choice&lt;/a&gt;, las respuestas estructuradas con las que construí la clasificación.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://api.typesafe.ai/docs" rel="noopener noreferrer"&gt;Referencia de la API System One&lt;/a&gt;, para ver el contrato de las consultas.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://learn.microsoft.com/graph/api/message-permanentdelete?view=graph-rest-1.0&amp;amp;wt.mc_id=studentamb_641347" rel="noopener noreferrer"&gt;Borrado permanente de mensajes (Microsoft Graph)&lt;/a&gt;, guía oficial con los permisos y la carpeta de purgas.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  Sobre mí
&lt;/h3&gt;

&lt;p&gt;Soy Fernando Peralta, desarrollador web y Technical Lead en &lt;a href="https://www.devstoremx.xyz/" rel="noopener noreferrer"&gt;Devstoremx&lt;/a&gt;, una agencia de desarrollo web en México donde trabajamos en sitios web, aplicaciones a medida, ecommerce, integraciones y automatizaciones.&lt;/p&gt;

&lt;p&gt;También comparto proyectos personales, experiencias y artículos técnicos en &lt;a href="https://fernandoperalta.xyz/es/" rel="noopener noreferrer"&gt;mi sitio web y blog de desarrollo&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Si prefieres leer este caso con el diseño y las ilustraciones originales, visita &lt;a href="https://fernandoperalta.xyz/es/blog/como-libere-12gb-hotmail-jev/" rel="noopener noreferrer"&gt;el artículo completo en mi blog&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>ai</category>
      <category>sqlite</category>
      <category>productivity</category>
    </item>
  </channel>
</rss>
