Why is there a bunch of WordPress HTML code in my browser CSV download?

This code cannot run in a shortcode:

if ( $_SERVER['REQUEST_METHOD'] == 'GET' && isset($_GET['CSV']) ) {
    require_once __DIR__ . '/../assets/helpers.php'; // contains csv_download() function
    csv_download();
}

The problem is that by the time a shortcode executes it’s too late to send HTTP headers, and WordPress has already sent the theme header and HTML body tags. Shortcodes are meant for inserting dynamic content into post content, not this.

That doesn’t mean you can’t use a shortcode for your form, just that the csv_download needs to be called much earlier, outside the shortcode.

For example, on the init hook:

// if the user has pressed the button
if ( $_SERVER['REQUEST_METHOD'] == 'GET' && isset($_GET['CSV']) ) {
    // do the csv download on the 'init' hook/action/event instead of the shortcode
    add_action( 'init', 'bistromatic_generate_csv_download' );
}
function bistromatic_generate_csv_download() {
    require_once __DIR__ . '/../assets/helpers.php'; // contains csv_download() function
    csv_download();
}

function email_list_shortcode($atts = [], $content = null)
{
    ob_start();
    ....... etc

This would work because the code runs before any theme templates are loaded. This does mean that the add_action call must happen in a plugin or functions.php, and it can’t be inside another function or a file included at a later point or on a hook. Timing and the order of execution is critical.