Single Email
Send a single email via the API
Send a single email with HTML/text content, optional attachments, custom headers, and scheduled delivery.
The From domain must be verified by this project
A send is refused with a 400 unless the domain in from has been verified under
Domains
by the project making the request. That holds for every surface —
this endpoint, templates, batches, campaigns, and the SMTP relay — and it is checked again at delivery, so a scheduled
message will not go out on a domain that was unverified while it waited.
The project comparison is the point. Domain names are globally unique across an install, so without it any project could
put a neighbour’s domain in from. A domain nobody has verified and a domain another project owns give the same
message, which is deliberate: the answer must not reveal which.
A verified domain covers its subdomains: verify example.com and
news@mail.example.com sends. Whoever controls a zone controls every name under it. Matching is by whole labels, so
evilexample.com is not covered.
Subdomain mail is DKIM-signed with the verified ancestor’s key, meaning
d=example.com. That aligns under DMARC’s default relaxed policy. If you publish
adkim=s or aspf=s, verify the subdomain separately so it gets a key of its own.
Machine API schema
The /api/v1 surface describes itself: run
mailyard export-api-spec
and feed the document to Swagger UI, Postman,
or a client generator.
Example
curl -X POST http://localhost:3000/api/v1/emails/send \
-H "Authorization: Bearer myk_..." \
-H "Content-Type: application/json" \
-d '{
"from": "billing@example.com",
"to": ["jane@customer.example"],
"subject": "Receipt for order 4471",
"html": "<p>Thanks, Jane. Your receipt is below.</p>",
"text": "Thanks, Jane. Your receipt is below.",
"headers": {
"X-Order-Ref": "4471"
},
"list_unsubscribe_url": "https://example.com/mail/stop",
"list_unsubscribe_post": true
}'
What is required
Four things, and the request is refused with a 400 naming the one that is wrong:
from, a parseable address whose domain this project has verifiedto, at least one parseable address, within the installation’s recipient ceilingsubject, non-empty- a body —
htmlortext, or both
Who sees whom
Every address in to and cc is written into the message headers, so every recipient sees every other one. A
bcc address receives the message and appears in no header at all. All three lists count towards the recipient
ceiling, and the same mailbox named twice is delivered once.
{
"to": ["jane@customer.example"],
"cc": ["account-manager@yourapp.example"],
"bcc": ["archive@yourapp.example"]
}
to is required even when bcc carries the real audience, because a message with an empty To header scores badly
with spam filters. Put your own address in to and the audience in bcc, or send one message per recipient - that
is what /emails/batch is for.
Sending both parts is worth the extra field. Clients that cannot render HTML fall back to the text part, and a message with no text alternative scores worse with spam filters than one that has it.
Optional fields
| Field | Does |
|---|---|
cc, bcc | The other two recipient lists - see Who sees whom |
reply_to | Where a reply lands when it should not go back to from. Any parseable address, verified or not |
headers | Up to 20 custom headers |
attachments | Base64 files — see Attachments |
send_at | Hold until an RFC 3339 time — see Scheduled Email |
dry_run | Run every validation and persist nothing |
disable_tracking | Opt this message out of open and click tracking |
unsubscribe_list_id | Send under a transactional opt-out scope |
list_unsubscribe_url, list_unsubscribe_mailto, list_unsubscribe_post | Carry your own opt-out targets |
smtp_group, smtp_server_id | Pin the route out |
sandbox, sandbox_retention_days | Capture instead of delivering — see Sandbox |
dry_run is the cheapest way to check an integration: it validates the sender, the recipients, the headers, the
attachment sizes and the routing, then returns without writing a row or spending quota.
Sixteen headers are reserved
Anything the message builder owns is refused rather than merged, so a caller cannot forge the envelope or break the
MIME structure: From, To, Cc, Bcc, Reply-To, Subject, Date, MIME-Version, Content-Type,
Content-Transfer-Encoding, List-Unsubscribe, List-Unsubscribe-Post, Return-Path, Message-ID, Received and
DKIM-Signature.
The refusal names the header and, where there is one, the field to use instead — List-Unsubscribe points you at
list_unsubscribe_url, Reply-To at reply_to, Cc and Bcc at cc and bcc. Matching is case-insensitive, and a header name or value containing a newline is refused
outright, which is what stops header injection through a value you interpolated.
What comes back
201, with the stored record and anything that was dropped:
{
"email": {
"id": "0198f6a1-3c7e-7b21-9f4d-2a5c8e0b1d33",
"status": "queued",
"recipients": ["jane@customer.example"],
"subject": "Receipt for order 4471"
},
"suppressed_recipients": []
}
Read suppressed_recipients. Addresses on the project’s
suppression list
are removed before sending and named here. The call still succeeds —
suppression is a standing instruction, not an error — so an address silently missing from a delivery is usually here.
If every recipient was suppressed, the row is still created, with status suppressed and an error message saying
so. Nothing leaves the building.
After it is accepted
The message is queued and a worker picks it up, normally within a second. Follow it with
the status route
, which also explains what each state means and why sent is not
the same as delivered.