OquMail
Loading account…
Email API3 min readUpdated

Send Email from AWS Lambda Without SES: HTTPS API in Node or Python

Send email from a Lambda function on your own domain with one HTTPS call: a Node 20 handler, a Python variant, where to keep the key, and safe SQS retries.

Lambda functions can open outbound HTTPS freely but have no business talking SMTP: port 25 is blocked, long-lived connections are wasted in a function that lives for a second, and SES means a separate sandbox, a verification dance and another bill. One HTTPS POST to a mailbox on your own domain does the job in a few lines. Here is a Node 20 handler, a Python 3.12 variant, where to keep the key, and how to stop SQS retries from sending the same message twice.

Quick answer

Create an API key on the mailbox you want to send from, put it in the function's environment as OQUMAIL_API_KEY (or in SSM Parameter Store as a SecureString), and POST JSON to https://api.oqumail.com/api/v1/emails with to as an array, subject and html or text. Node 18 and later have fetch built in; Python has urllib. Use the function's timeout and the SQS redrive policy as your retry mechanism, and dedupe on your own event id.

Node.js 20 handler

Triggered by an SQS queue of events such as {"type":"order_paid","email":"...","orderId":"..."}. The function sends one message per record and reports partial failures so SQS retries only the records that failed:

// index.mjs  (runtime: nodejs20.x)
const ENDPOINT = "https://api.oqumail.com/api/v1/emails";
const KEY = process.env.OQUMAIL_API_KEY;

async function send(to, subject, html) {
  const res = await fetch(ENDPOINT, {
    method: "POST",
    headers: {
      "Authorization": "Bearer " + KEY,
      "Content-Type": "application/json"
    },
    body: JSON.stringify({ to: [to], subject, html, fromName: "Acme Orders" })
  });
  if (res.status === 202) return res.json();          // { id, messageId, status }
  if (res.status === 429 || res.status === 403) {
    throw new Error("retry later: " + res.status);     // SQS will redeliver
  }
  const body = await res.text();
  throw new Error("send failed " + res.status + ": " + body);
}

export const handler = async (event) => {
  const failures = [];
  for (const record of event.Records) {
    const job = JSON.parse(record.body);
    try {
      await send(job.email, "Order " + job.orderId + " confirmed",
        "<p>Thanks, your order " + job.orderId + " is confirmed.</p>");
    } catch (err) {
      console.error(record.messageId, err.message);
      failures.push({ itemIdentifier: record.messageId });
    }
  }
  return { batchItemFailures: failures };
};

Python 3.12 variant

import json, os, urllib.request

ENDPOINT = "https://api.oqumail.com/api/v1/emails"
KEY = os.environ["OQUMAIL_API_KEY"]

def send(to, subject, text):
    payload = json.dumps({"to": [to], "subject": subject, "text": text}).encode()
    req = urllib.request.Request(ENDPOINT, data=payload, method="POST", headers={
        "Authorization": "Bearer " + KEY,
        "Content-Type": "application/json",
    })
    with urllib.request.urlopen(req, timeout=10) as res:
        return json.load(res)           # 202 -> {"id": ..., "status": "queued"}

def handler(event, context):
    for record in event["Records"]:
        job = json.loads(record["body"])
        send(job["email"], "Your report is ready", "Download it from your dashboard.")
    return {"ok": True}

Where to keep the key

  • Lambda environment variable: simplest, encrypted at rest with the default KMS key. Fine for one function.
  • SSM Parameter Store SecureString: one place for several functions, fetched at cold start and cached in a module-level variable. Give the function ssm:GetParameter on that one parameter.
  • Secrets Manager: same idea with rotation hooks; worth it only if you already use it.
  • Never in the deployment package or the repository. Rotate the key in the OquMail dashboard if it ever lands in a log.

Free business email on your own domain

OquMail gives you up to 15 mailboxes on your domain — free — with guided SPF/DKIM/DMARC, webmail, IMAP/SMTP for any mail app, and a send API. Setup is a few DNS records; changes usually take effect within minutes, occasionally up to 48 hours. Start at oqumail.com.

Get started free

Retries and duplicates

SQS redelivers a record when the function throws or times out, and Lambda itself retries asynchronous invocations twice. That is the right behaviour for a 429 or 403, and the wrong behaviour for a message that was already accepted with 202 before something else failed. Store your own event id (the order id, the SQS message id) with the API's returned id in a table, check it before sending, and skip if present. The idempotency guide has the pattern.

What the API answers

  • 202 Accepted: the message is queued. The JSON carries id, messageId and status; store the id so a support question can be matched to the delivery log.
  • 400: the body failed validation, usually to missing or not an array, or neither html nor text present.
  • 401: the key is missing, revoked or mistyped.
  • 403: the mailbox is suspended, has reached its daily limit of 250, or sent more than 10 messages in a minute ("Sending too fast"). Back off; do not retry in a tight loop.
  • 422: the message was blocked by content checks; the response explains why. Do not retry unchanged.
  • 429: requests arrived faster than the per-key limit. Honour the Retry-After header.

Common questions

Does the function need to be in a VPC?

No. Outbound HTTPS works from the default Lambda network. If it is in a VPC for other reasons, it needs a NAT gateway or a VPC endpoint route to reach the internet.

Can I attach the PDF the function generated?

Yes: attachments is an array of {filename, contentType, contentBase64}. For anything over a megabyte, upload to S3 and send a presigned link instead; the invoice email guide explains why.

What about SES in the sandbox?

SES in the sandbox can only send to verified addresses and needs a production request. The API has no sandbox: a mailbox on a verified domain sends to anyone, within the daily limit of 250 per mailbox.