If you need C# send email code that will still look right in a year, the short answer is: call an email API over HTTPS with HttpClient, or use MailKit if you must speak SMTP. Ruby on Rails and Go have their own idioms (Action Mailer's deliver_now and deliver_later, Go's frozen net/smtp package), but all three reduce to the same two transports. This guide gives a complete, runnable example for each language against one real endpoint, POST https://api.volanea.com/v1/send, and calls out the traps that cause most "it worked locally" bugs.
The request every example sends
Every HTTP example below sends the same JSON body. It is worth knowing the shape before you translate it into three languages:
{
"from": "orders@yourdomain.com",
"fromName": "Acme",
"to": ["customer@example.com"],
"subject": "Your order #1042 has shipped",
"html": "<p>Your order is on its way.</p>",
"text": "Your order is on its way."
}
A few details trip people up:
fromis a bare address. The display name goes infromName."Acme <orders@yourdomain.com>"infromfails validation with a 422. The address must be on a domain you have verified.totakes a string or an array of up to 50 recipients for one message. For many different messages, use the batch endpoint (/docs/api-reference/send).- Send
html,text, or both. Both is better: the plain-text part helps clients that do not render HTML. - Authentication is a Bearer header:
Authorization: Bearer $VOLANEA_API_KEY. Keep the key on the server, never in browser or mobile code. Idempotency-Keyheader (optional). If you retry after a timeout with the same key and body, the API replays the stored response instead of sending a second email. Use a stable business id such asorder-1042-shipped.
A success returns { "success": true, "data": { "messages": [...], "testMode": false } }. An error returns { "success": false, "error": "...", "code": "..." }; branch on code, not on the wording. Keys that start with sk_test_ render and log the message but never deliver it, which is useful for running the samples below without emailing anyone.
The same request in Node.js, Python and PHP is covered in /docs/send-with-nodejs, /docs/send-with-python and /docs/send-with-php.
C# send email with HttpClient (.NET 8+)
For a C# email integration, the simplest dependable path is plain HttpClient and System.Net.Http.Json, both part of modern .NET. Nothing to install:
dotnet new console -n SendEmail && cd SendEmail
export VOLANEA_API_KEY="sk_test_..."
Replace Program.cs:
using System.Net.Http.Headers;
using System.Net.Http.Json;
var apiKey = Environment.GetEnvironmentVariable("VOLANEA_API_KEY")
?? throw new InvalidOperationException("Set VOLANEA_API_KEY first.");
using var http = new HttpClient { BaseAddress = new Uri("https://api.volanea.com/") };
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
using var request = new HttpRequestMessage(HttpMethod.Post, "v1/send")
{
Content = JsonContent.Create(new
{
from = "orders@yourdomain.com",
fromName = "Acme",
to = new[] { "customer@example.com" },
subject = "Your order #1042 has shipped",
html = "<p>Your order is on its way.</p>",
text = "Your order is on its way."
})
};
request.Headers.Add("Idempotency-Key", "order-1042-shipped");
using var response = await http.SendAsync(request);
var body = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
throw new HttpRequestException($"Send failed ({(int)response.StatusCode}): {body}");
Console.WriteLine(body);
Run it with dotnet run. Three things matter once this moves into a real app:
- Reuse the client. In ASP.NET Core, register it with
builder.Services.AddHttpClient(...)and inject it, instead of creating anHttpClientper email. The .NET guide shows theIHttpClientFactoryversion. - Always
await..Result,.Wait()and fire-and-forget calls hide failures and can deadlock. - Move important sends to a background worker. Write an outbound-email job in the same transaction as the order, then send from a hosted service. A process restart then cannot lose the receipt.
C# email over SMTP: use MailKit, not System.Net.Mail
Many older C# tutorials use System.Net.Mail.SmtpClient. Microsoft's own API reference now says: "We don't recommend that you use the SmtpClient class for new development because SmtpClient doesn't support many modern protocols. Use MailKit or other libraries instead." The class still works in .NET, but it is a poor base for new code.
MailKit (with MimeKit for building messages) is the library Microsoft points to. It supports .NET 8 and later:
dotnet add package MailKit
using MailKit.Net.Smtp;
using MailKit.Security;
using MimeKit;
var host = Environment.GetEnvironmentVariable("VOLANEA_SMTP_HOST")!;
var port = int.Parse(Environment.GetEnvironmentVariable("VOLANEA_SMTP_PORT")!);
var apiKey = Environment.GetEnvironmentVariable("VOLANEA_API_KEY")!;
var message = new MimeMessage();
message.From.Add(new MailboxAddress("Acme", "orders@yourdomain.com"));
message.To.Add(MailboxAddress.Parse("customer@example.com"));
message.Subject = "Your order #1042 has shipped";
message.Body = new BodyBuilder
{
TextBody = "Your order is on its way.",
HtmlBody = "<p>Your order is on its way.</p>"
}.ToMessageBody();
using var client = new SmtpClient(); // MailKit's SmtpClient, not System.Net.Mail's
var tls = port == 465 ? SecureSocketOptions.SslOnConnect : SecureSocketOptions.StartTls;
await client.ConnectAsync(host, port, tls);
await client.AuthenticateAsync("volanea", apiKey);
await client.SendAsync(message);
await client.DisconnectAsync(true);
Take the host and port from the SMTP settings in your Volanea dashboard. The username is volanea and the password is the project's secret API key. SecureSocketOptions.StartTls fails if the server cannot upgrade the connection, which is what you want. SecureSocketOptions.Auto is looser and can continue unencrypted. Port 465 conventionally means TLS from the first byte, so the sample switches to SslOnConnect there.
Ruby on Rails mailer: Action Mailer done properly
Rails mail goes through Action Mailer. Generate a mailer:
bin/rails generate mailer User
# app/mailers/application_mailer.rb
class ApplicationMailer < ActionMailer::Base
default from: email_address_with_name("orders@yourdomain.com", "Acme")
layout "mailer"
end
# app/mailers/user_mailer.rb
class UserMailer < ApplicationMailer
def welcome_email
@user = params[:user]
mail(to: @user.email, subject: "Welcome to Acme")
end
end
Add app/views/user_mailer/welcome_email.html.erb and welcome_email.text.erb, and Rails builds a multipart message with both. Set config.action_mailer.default_url_options = { host: "yourapp.com" }, or *_url helpers in the views will raise.
Rails deliver now vs deliver later
UserMailer.with(user: @user).welcome_email.deliver_now # sends in this process, now
UserMailer.with(user: @user).welcome_email.deliver_later # enqueues ActionMailer::MailDeliveryJob
deliver_now makes the network call inside the current request or job. Use it inside a job you already control, in a rake task, or in a console. deliver_later hands the message to Active Job and returns immediately. Use it from controllers so a slow mail server does not slow your response. deliver_later(wait: 1.hour) and deliver_later(wait_until: time) schedule it.
Two deliver_later gotchas come straight from the Active Job guide:
- The backend matters. The in-process
:asyncadapter keeps jobs in memory, and "if the process crashes or the machine is reset, then all outstanding jobs are lost." Rails 8 uses Solid Queue by default, which stores jobs in your database. Make sure production uses a durable backend. - Records are serialized by GlobalID, not copied. If the user is deleted before the job runs, the job raises
ActiveJob::DeserializationError. The mail is also rendered when the job runs, not when you enqueue it, so it shows the record's state at that later time.
Rails mail over SMTP
The SMTP delivery method needs no gems. In config/environments/production.rb:
config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = {
address: ENV.fetch("VOLANEA_SMTP_HOST"),
port: Integer(ENV.fetch("VOLANEA_SMTP_PORT")),
user_name: "volanea",
password: ENV.fetch("VOLANEA_API_KEY"),
authentication: :plain,
enable_starttls: true, # fail rather than send in clear text
open_timeout: 5,
read_timeout: 5
}
enable_starttls: true refuses to continue if STARTTLS is not available. The default enable_starttls_auto only upgrades when the server offers it. If your dashboard lists an implicit-TLS port, use tls: true instead of enable_starttls. The Rails guide for this path, with deliverability notes, is at /docs/send-with-rails-smtp.
Send Action Mailer through the HTTP API
You can keep mailers, views and previews and swap only the transport. Action Mailer accepts custom delivery methods through add_delivery_method. A class that takes settings in its constructor and implements deliver!(mail) is enough:
# config/initializers/volanea_delivery.rb
require "net/http"
require "json"
class VolaneaDelivery
attr_accessor :settings
def initialize(settings)
@settings = settings
end
def deliver!(mail)
raise ArgumentError, "cc/bcc are not supported here" if mail.cc.present? || mail.bcc.present?
sender = mail[:from].addrs.first
html = mail.html_part || (mail if mail.mime_type == "text/html")
text = mail.text_part || (mail if mail.mime_type == "text/plain")
payload = {
from: sender.address,
fromName: sender.display_name,
to: mail.to,
replyTo: mail.reply_to&.first,
subject: mail.subject,
html: html&.body&.decoded,
text: text&.body&.decoded
}.compact
uri = URI("https://api.volanea.com/v1/send")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{settings.fetch(:api_key)}"
request["Content-Type"] = "application/json"
request.body = JSON.generate(payload)
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 5, read_timeout: 15) do |http|
http.request(request)
end
raise "Volanea send failed (#{response.code}): #{response.body}" unless response.is_a?(Net::HTTPSuccess)
end
end
ActiveSupport.on_load(:action_mailer) do
add_delivery_method :volanea, VolaneaDelivery, api_key: ENV["VOLANEA_API_KEY"]
end
Then set config.action_mailer.delivery_method = :volanea. The class is defined in an initializer rather than lib/ so it loads once at boot and does not run into Zeitwerk's rule against autoloading during initialization. Attachments are left out to keep the example short; the API accepts up to 10, base64-encoded. The API-first Rails walkthrough is at /docs/send-with-rails.
Testing Rails mail
In config/environments/test.rb, config.action_mailer.delivery_method = :test appends each message to ActionMailer::Base.deliveries instead of sending it. assert_emails 1 { ... } works for both deliver_now and deliver_later, and assert_enqueued_emails checks only that a message was queued. For visual checks, preview classes in test/mailers/previews render at http://localhost:3000/rails/mailers.
"Mailbox" in Rails means inbound
Action Mailbox is the other half: it "routes incoming emails to controller-like mailboxes for processing in your Rails application." Install it with bin/rails action_mailbox:install. Its built-in ingresses are Exim, Postfix, Qmail, and the Mailgun, Mandrill, Postmark and SendGrid webhooks. Action Mailbox does not send anything; Action Mailer does.
Ruby send email without Rails
There is no need to wait for a Ruby SDK. The standard library covers it:
require "net/http"
require "json"
uri = URI("https://api.volanea.com/v1/send")
response = Net::HTTP.post(
uri,
JSON.generate(from: "orders@yourdomain.com", to: ["customer@example.com"],
subject: "Your order #1042 has shipped", text: "Your order is on its way."),
"Authorization" => "Bearer #{ENV.fetch('VOLANEA_API_KEY')}",
"Content-Type" => "application/json",
"Idempotency-Key" => "order-1042-shipped"
)
puts response.code, response.body
More Ruby patterns (Sinatra, plain scripts, retries) are in /docs/send-with-ruby.
Go: an HTTP API call and net/smtp
Go with net/http
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"time"
)
type sendRequest struct {
From string `json:"from"`
FromName string `json:"fromName,omitempty"`
To []string `json:"to"`
Subject string `json:"subject"`
HTML string `json:"html,omitempty"`
Text string `json:"text,omitempty"`
}
func main() {
apiKey := os.Getenv("VOLANEA_API_KEY")
if apiKey == "" {
log.Fatal("VOLANEA_API_KEY is not set")
}
payload, err := json.Marshal(sendRequest{
From: "orders@yourdomain.com",
FromName: "Acme",
To: []string{"customer@example.com"},
Subject: "Your order #1042 has shipped",
HTML: "<p>Your order is on its way.</p>",
Text: "Your order is on its way.",
})
if err != nil {
log.Fatal(err)
}
req, err := http.NewRequest(http.MethodPost, "https://api.volanea.com/v1/send", bytes.NewReader(payload))
if err != nil {
log.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "order-1042-shipped")
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Fatal(err)
}
if resp.StatusCode >= 300 {
log.Fatalf("send failed (%d): %s", resp.StatusCode, body)
}
fmt.Println(string(body))
}
Set a client timeout. The zero-value http.Client has none, so a stalled connection can hang a goroutine forever.
Go with net/smtp
The standard library's net/smtp works, but its own documentation says "the smtp package is frozen and is not accepting new features." For a few transactional messages it is fine:
package main
import (
"log"
"net"
"net/smtp"
"os"
"strings"
"time"
)
func main() {
host := os.Getenv("VOLANEA_SMTP_HOST")
port := os.Getenv("VOLANEA_SMTP_PORT")
apiKey := os.Getenv("VOLANEA_API_KEY")
from := "orders@yourdomain.com"
to := []string{"customer@example.com"}
msg := strings.Join([]string{
"From: Acme <" + from + ">",
"To: " + to[0],
"Subject: Your order #1042 has shipped",
"Date: " + time.Now().Format(time.RFC1123Z),
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=UTF-8",
"",
"Your order is on its way.",
}, "\r\n")
auth := smtp.PlainAuth("", "volanea", apiKey, host)
if err := smtp.SendMail(net.JoinHostPort(host, port), auth, from, to, []byte(msg)); err != nil {
log.Fatal(err)
}
log.Println("accepted by the SMTP server")
}
The details matter here:
SendMail"switches to TLS if possible" via STARTTLS.PlainAuth"will only send the credentials if the connection is using TLS or is connected to localhost", so a server without STARTTLS gets an error, not your key in clear text.SendMaildoes not do implicit TLS. For a port that expects TLS from the first byte, dial withtls.Dialand wrap the connection withsmtp.NewClient.- The message is raw RFC 822 text: headers, a blank line, then the body, with CRLF line endings. Never put unsanitized user input into a header line.
- Building multipart HTML by hand is tedious. Past this point, the HTTP call above is simpler. The SMTP version with a maintained mail library is in /docs/send-with-go.
Django email send, briefly
Django's send_mail uses the SMTP backend once you set EMAIL_HOST and EMAIL_PORT to the values from your dashboard, EMAIL_HOST_USER = "volanea", EMAIL_HOST_PASSWORD from the environment, and EMAIL_USE_TLS = True. Full walkthroughs: Django overview, Django via the API and Django over SMTP.
HTTP API or SMTP?
| HTTP API | SMTP | |
|---|---|---|
| Error detail | JSON code and message per request | SMTP reply codes, often after the connection is open |
| Retries without duplicates | Idempotency-Key header | None built in |
| Templates, variables, scheduling | Fields on the request (templateId, variables, sendAt) | Build the MIME yourself |
| Works with existing mail libraries (Action Mailer SMTP, MailKit, Django) | Needs a small adapter | Drop-in |
| Firewalls | Port 443 | Outbound SMTP ports must be open |
Use the API for new code. Use SMTP when a framework or third-party app only speaks SMTP.
How Volanea handles this
Volanea is a developer-first email platform built on Amazon SES. SMTP and the HTTP API share one send pipeline, so a message from MailKit and one from HttpClient get the same suppression checks, bounce and complaint handling, and delivery webhooks. SPF, DKIM and DMARC are set up for you when you verify a sending domain, including on the free plan. The full request schema is in the send API reference.
FAQ
How do I send email in C#?
Make a POST with HttpClient to your provider's email API with a JSON body (from, to, subject, html), as in the first C# example. If you need SMTP, use MailKit. Microsoft does not recommend System.Net.Mail.SmtpClient for new development.
Is System.Net.Mail.SmtpClient deprecated?
It is not removed, and it still works in .NET. But Microsoft's API reference says it is not recommended for new development because it "doesn't support many modern protocols", and it points to MailKit instead.
What is the difference between deliver_now and deliver_later in Rails?
deliver_now renders and sends the email immediately in the current process. deliver_later enqueues an ActionMailer::MailDeliveryJob through Active Job and sends it when a worker runs the job. It only survives restarts if your Active Job backend is durable.
Why does deliver_later work locally but not in production?
Usually the job is queued and no worker is processing that queue, or production is on the in-memory :async adapter and jobs were lost on a restart. Check your queue backend and that a worker process is running.
Is there a Ruby SDK for sending email?
You do not need one. A single Net::HTTP.post call, or a custom Action Mailer delivery method like the one above, covers it with only the standard library.
Can Go's net/smtp send HTML email?
Yes, if you write the MIME headers yourself (Content-Type: text/html; charset=UTF-8, or a multipart/alternative body for HTML plus text). The package is frozen, so for anything beyond simple messages an HTTP API call or a maintained library is easier.
You can run every example above on the free plan: create a Volanea account (1,000 emails a month, no card), or see pricing.