Email

Sending Email From Your App: SMTP, API and the Right Approach

Password resets, verification and invoice notifications need more than a mail() call. SMTP vs API, why mail lands in spam, and why your own domain matters — from an app developer's perspective.

Author: Skyversal Team

You built an app, sign-up works, everything is fine. Then you add password reset and things get complicated: is the email sending, is it landing in spam, did the user even get it?

This article covers what it takes to make "sending email" actually work. It focuses less on which library to pick and more on why — because most problems surface in the infrastructure, not the code.

First, the reframe: sending email is reputation management

If you make an HTTP request and get a 200 back, you're done. Email is different. For your mail to be accepted, the receiving side must recognise you:

  • Is this IP really sending on behalf of that domain? (SPF)
  • Did the message really originate from that domain, unmodified? (DKIM)
  • Does that domain claim this message? (DMARC)

Without all three, Gmail and Outlook will reject your mail — landing in spam would be a luxury. Since 2024, Gmail refuses unauthenticated bulk senders outright with 550 5.7.26:

550-5.7.26 Gmail requires all senders to authenticate with either SPF or DKIM.
  DKIM = did not pass
  SPF [example.com] with ip [203.0.113.10] = did not pass

So when you add an email feature, you are really configuring DNS. That is the architectural decision.

SMTP or API?

Technically both do the same job. The difference is who manages what:

SMTPHTTP API
IntegrationMature libraries for every languageProvider-specific SDK
DebuggingYou can talk to it by hand with telnetRead the response body
Feature setStandard (RFC 5321)Templates, webhooks, analytics
Lock-inLow — every provider supports itDepends on provider

Rule of thumb: for transactional mail (password reset, invoices) SMTP is enough and portable. For marketing mail (campaigns, segmentation) an API is richer.

Most projects start with SMTP and stay there. That's a sound choice — smtplib, nodemailer, System.Net.Mail exist everywhere and behave predictably.

Why your own domain matters

Two options:

  1. Shared subdomain — yourproject.provider.com. Quick start, no DNS work.
  2. Your own domain — no-reply@yourcompany.com.

The first looks attractive but carries a hidden cost: you share reputation. If another tenant sending from the same domain runs a spam campaign, that domain gets blacklisted and your password reset email dies with it. Nothing changed in your code, yet your system is broken.

On your own domain the reputation is yours. If it degrades, the only sending behaviour you need to fix is yours.

That's why on Skyversal PaaS attaching your own domain is free — it serves the platform's interest too.

Code: working examples in three languages

All examples read the same environment variables, so switching providers doesn't change your code:

MAIL_HOST=smtp.example.com
MAIL_PORT=587
MAIL_USERNAME=no-reply@yourcompany.com
MAIL_PASSWORD=********

Python

import smtplib
from email.message import EmailMessage
from email.utils import make_msgid, formatdate
import os

msg = EmailMessage()
msg["Subject"]    = "Password reset"
msg["From"]       = "no-reply@yourcompany.com"
msg["To"]         = user_email
# Message-ID IS REQUIRED: Gmail rejects mail without it (550)
msg["Message-ID"] = make_msgid(domain="yourcompany.com")
msg["Date"]       = formatdate(localtime=True)
msg.set_content("Reset link: https://...")

with smtplib.SMTP(os.environ["MAIL_HOST"], 587, timeout=30) as s:
    s.ehlo()
    s.starttls()                                  # STARTTLS is mandatory
    s.login(os.environ["MAIL_USERNAME"], os.environ["MAIL_PASSWORD"])
    s.send_message(msg)

Node.js

const nodemailer = require("nodemailer");

const transport = nodemailer.createTransport({
  host: process.env.MAIL_HOST,
  port: 587,
  secure: false,          // 587 = STARTTLS, 465 = implicit TLS
  auth: {
    user: process.env.MAIL_USERNAME,
    pass: process.env.MAIL_PASSWORD,
  },
});

await transport.sendMail({
  from: "no-reply@yourcompany.com",
  to: userEmail,
  subject: "Password reset",
  text: "Reset link: https://...",
});

.NET

using System.Net;
using System.Net.Mail;

// NOTE: .NET SmtpClient supports STARTTLS only.
// EnableSsl = true  -> STARTTLS (port 587).
// Implicit TLS (port 465) is NOT supported.
using var client = new SmtpClient(host, 587)
{
    EnableSsl   = true,
    Credentials = new NetworkCredential(user, pass),
};

var mail = new MailMessage("no-reply@yourcompany.com", userEmail)
{
    Subject = "Password reset",
    Body    = "Reset link: https://...",
};
await client.SendMailAsync(mail);

Watch out: .NET's SmtpClient does not support port 465 (implicit TLS). With 465 the connection opens but the handshake fails. 587 + EnableSsl = true is the correct combination.

Real reasons mail ends up in spam

If authentication is right, the mail is sent. Landing in spam is a separate matter — usually one of these:

  • Missing Message-ID — Gmail rejects it outright (550 5.7.1). Most libraries add it, but a hand-built EmailMessage needs it explicitly.
  • No reverse DNS (PTR) — the sending IP should resolve to a hostname that resolves back to the same IP (FCrDNS).
  • Content/header mismatch — subject "Your invoice", body a single link.
  • Sudden volume spike — 10,000 emails in a day from a fresh IP. Warm-up is required.
  • Link shorteners — bit.ly-style services raise the spam score.

Checklist

Before going live:

  • ☐ SPF record exists and includes the sending IP
  • ☐ DKIM signature passes (dkim=pass in Authentication-Results)
  • ☐ DMARC record exists (_dmarc TXT)
  • ☐ PTR record resolves to the sending hostname
  • ☐ Every message carries Message-ID and Date
  • ☐ 587 + STARTTLS is used (not 465)
  • ☐ There is a failure path: if sending fails, the user gets a meaningful message

Are you going to run the infrastructure yourself?

Running Postfix, a DKIM signing service, quotas and abuse prevention is a product of its own. For most applications it produces more work than the product itself.

On Skyversal PaaS that layer is done: 200 emails/day free on every plan, SPF/DKIM/PTR handled on our side, and attaching your own domain is free. In your code you only read environment variables.

Technical docs: Skyversal documentation.

Last updated: October 24, 2026