sugarcraft / candy-mail
Email sender for PHP, modeled on the charmbracelet/pop Go tool — send via the Resend API or direct SMTP. Compose from stdin, attach files, supports CC/BCC, HTML body.
Requires
- php: ^8.3
- sugarcraft/sugar-async: dev-master
- sugarcraft/sugar-core: dev-master
Requires (Dev)
- phpstan/phpstan: ^2.2
- phpstan/phpstan-phpunit: ^2.0
- phpunit/phpunit: ^10.5
Suggests
- ext-curl: Required by the Resend API transport (ResendTransport); direct SMTP works without it
- ext-fileinfo: Enables automatic MIME type detection for attachments created from file paths
- ext-openssl: Required for STARTTLS and implicit-TLS SMTP connections
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-05 01:00:54 UTC
README
CandyMail
candy-mail — an email sender for PHP 8.3+, via the Resend API or direct SMTP. Send-only by design: candy-mail composes and dispatches mail; it does not read, store, or manage an inbox.
Formerly published as
sugarcraft/sugar-post(renamed 2026-10). Namespace changed:SugarCraft\Post\→SugarCraft\Mail\.
Features
- Dual transport — send via the Resend HTTP API or raw SMTP
- Email value object — from, to, cc, bcc, subject, body, HTML body, reply-to, attachments
- File attachments — attach from disk paths (
withAttachment()), or pass raw in-memory bytes withAttachment::fromContent()via the constructor'sattachments:parameter - Inline attachments (CID) —
withInlineAttachment()embeds images addressed ascid:…; honoured by the SMTP transport's MIME builder. The Resend API payload does not carrycid, so inline references break there — use SMTP for CID embedding. - CC/BCC support — full carbon-copy / blind carbon-copy routing (envelope RCPT for all, headers for visible sets)
- Header-injection defense — CR/LF in subjects, addresses, attachment names, and MIME types is refused, not folded
- TLS by default — STARTTLS is required on SMTP unless explicitly relaxed (see below); credentials are never sent over a plaintext channel
- CLI — one-shot sending with STDIN compose,
--renderfor offline MIME preview, and translated error messages (the CLI itself; the library is transport-agnostic) - PHP 8.3+ — pure PHP.
suggested extensions, honestly scoped:ext-curlis needed only for the Resend transport,ext-fileinfoimproves automatic MIME detection for path attachments, andext-opensslis required for TLS — without it the SMTP transport refuses every encrypted connection (STARTTLS and port-465 alike); only plain, unencrypted SMTP works without it.
Install
composer require sugarcraft/candy-mail
The bin/candy-mail bootstrap walks an autoload candidate ladder (package-local
vendor/autoload.php first, then the consumer project's ../../../autoload.php), so
the CLI runs from a standalone clone, a composer install, or a built phar (which reads
its own bundled vendor). In the monorepo checkout, php candy-mail/bin/candy-mail
works after scripts/refresh-deps.php.
Quick Start
Resend API
use SugarCraft\Mail\{Email, Mailer, ResendTransport}; $transport = new ResendTransport('re_xxxxxxxxxxxxx'); // or new ResendTransport('') to read RESEND_API_KEY $mailer = new Mailer($transport); $email = new Email( from: ['you@example.com'], to: ['them@example.com'], subject: 'Hello from CandyMail', body: 'Sent via the Resend API.', ); $mailer->send($email);
SMTP
use SugarCraft\Mail\{Email, Mailer, SmtpTransport}; // TLS is required by default: the send refuses to proceed (or to send // credentials) unless the channel is encrypted. $transport = new SmtpTransport( host: 'smtp.gmail.com', port: 587, username: 'you@gmail.com', password: 'your-16-char-app-password', // Gmail rejects raw account passwords ); $mailer = new Mailer($transport); $mailer->send(new Email( from: ['you@gmail.com'], to: ['them@example.com'], subject: 'Hello via SMTP', body: 'Sent directly via SMTP.', ));
Attachment
use SugarCraft\Mail\{Attachment, Email}; $email = (new Email(from: ['me@example.com'], to: ['you@example.com'])) ->withAttachment('invoice.pdf', '/path/to/invoice.pdf'); // from disk (eagerly read) // Raw in-memory bytes: build the Attachment and pass it in the constructor. $email2 = new Email( from: ['me@example.com'], to: ['you@example.com'], attachments: [Attachment::fromContent($csvBytes, 'report.csv', 'text/csv')], );
Note:
--attach/withAttachment()read arbitrary local paths. The caller is responsible for validating that passed paths are trusted. No path allow-list is enforced by design.
TLS & security (SMTP)
| Port | Mode | What happens |
|---|---|---|
| 465 | Implicit TLS (withImplicitTls()) |
Socket is TLS-wrapped at connect; STARTTLS never sent. |
| 587 | STARTTLS (default) | Plaintext connect, then STARTTLS; required — if the server does not advertise it or the upgrade fails, the send throws instead of downgrading. |
| 25 | STARTTLS opportune-but-required | Same default policy applies: a server that refuses STARTTLS fails the send while requireTls is on. |
- Fail-closed credentials.
AUTH LOGINnever runs on an unencrypted channel — regardless ofrequireTls. There is noallowInsecureAuthescape hatch by design. - Opt out (trusted networks only).
->withRequireTls(false)restores the historical opportunistic behaviour for the STARTTLS upgrade itself; it does not re-open the credential floor above. - Self-signed / private CAs. Peer verification is always on (never blanket-disable
verify_peer). Point the handshake at your bundle with->withCaFile('/path/ca.pem'); it feeds both the implicit-TLS stream context and the STARTTLS upgrade. - Message integrity. Attachment filenames and MIME types pass the same CR/LF gate as subjects and addresses; MIME part parameters are RFC 2045-quoted.
- Secret masking.
__debugInfo()masks the SMTP password and the Resend API key. - Credentials are unexportable. Transports refuse
serialize()andvar_export()re-import (__serialize/__set_statethrow); construct a credential-holding transport where it is used. - Attachment ceilings. 25 MiB (
Attachment::MAX_BYTES) per attachment, regular files only — character devices like/dev/zeroare refused before any read. - Wire format discipline. Header lines fold at 998 octets using ≤75-char RFC 2047
encoded-words; non-ASCII filenames ride RFC 2231
filename*; 8-bit bodies downgrade to quoted-printable when the server lacks 8BITMIME (RFC 6152); documents end with a CRLF-terminated close-delimiter (RFC 2046 §5.1); servers answering EHLO with a 5xx fall back to HELO (RFC 5321 §4.1.4) — 4xx refusals still abort. - Senders are never invented. The CLI refuses to run without
--fromorCANDY_MAIL_FROM; an empty Resend sender list throws instead of fabricatingunknown@localhost.
CLI
candy-mail --from "me@example.com" --to "you@example.com" --subject "Hello" # Body read from STDIN. Both --flag value and --flag=value work. candy-mail --to you@example.com --render < message.txt # Offline preview: prints the composed MIME message (with a declared-boundary, # RFC 2046-valid structure) and sends nothing — no transport env required.
--version prints the package version, --help the usage, repeat --to/--cc/--bcc/--attach
to accumulate, and a bare -- ends option parsing (later addresses are positional recipients).
Unknown options are rejected loudly, with a did-you-mean hint when a known flag is a
near miss.
Value switches: --from/--subject/--body/--reply-to/--html/--helo/--ca-file/--timeout
(the last three override their CANDY_MAIL_SMTP_* env rows, and the flags win over the
environment). A value that begins with a dash needs the inline form
(--subject=-50% off) — a switch is never stolen as another switch's value. The CLI
requires a --to recipient: cc/bcc alone ends in a loud refusal (the library itself
permits cc/bcc-only sends over SMTP; the Resend API needs at least one to).
Embedding behind another bin? require the bootstrap and call
exit(\SugarCraft\Mail\main($argv)); to keep exit codes honest.
Environment variables:
export RESEND_API_KEY=re_xxxxx # Resend API key (enables the Resend transport) export CANDY_MAIL_SMTP_HOST=smtp.gmail.com # SMTP host (Resend key wins if both set) export CANDY_MAIL_SMTP_PORT=587 # SMTP port (default: 587) export CANDY_MAIL_SMTP_USERNAME=user # SMTP username export CANDY_MAIL_SMTP_PASSWORD=pass # SMTP password (app password for Gmail etc.) export CANDY_MAIL_SMTP_REQUIRE_TLS=1 # default ON; ON words 1/true/yes/on, OFF words 0/false/no/off — # anything else is REFUSED, never guessed (a typo'd security # switch must not silently downgrade) export CANDY_MAIL_SMTP_IMPLICIT_TLS=0 # set 1 for port-465 style direct TLS (same word vocabulary) export CANDY_MAIL_SMTP_HELO=mail.example.com # EHLO/HELO identity (letters, digits, dots, hyphens only) export CANDY_MAIL_SMTP_CA_FILE=/path/ca.pem # custom CA bundle for TLS verification export CANDY_MAIL_SMTP_TIMEOUT=30 # per-response seconds budget, 1..3600 (default 30) export CANDY_MAIL_FROM=me@example.com # Sender default — REQUIRED unless --from passed (never fabricated) export CANDY_MAIL_SIGNATURE="Sent with CandyMail" # Appended to body # Pre-rename POP_* exports map onto the 11 known CANDY_MAIL_* names only; # off-roster POP_* vars stay silent (their mapped name is read by nothing). // @rename-keep
Error messages follow the standard LC_ALL/LC_MESSAGES/LANG environment (16
locales ship); the usage/help text is English by design. Examples under examples/
are offline-safe: without real credentials they print the composed message instead of
dialling any API.
Internationalisation
All user-visible errors route through the SugarCraft\Mail\Lang facade (sugar-core's
shared T registry, namespace mail, catalog in lang/). English is the default;
a host application opts in process-wide:
\SugarCraft\Core\I18n\T::setLocale(\SugarCraft\Core\I18n\T::detect()); // LC_ALL → LC_MESSAGES → LANG → 'en'
The CLI performs this at startup; library embedders decide. Missing keys in an active locale fall back to English, never raw placeholders.
Architecture
Email— immutable email message value objectAttachment— immutable file attachment (path or inline content)Transport— interface for sending implementationsResendTransport— sends via Resend REST API (HTTPS)SmtpTransport— sends via direct SMTP (TCP/TLS)Mailer— high-level API wrapping a TransportLang— per-library translation facade over the shared sugar-core registry
Shared foundations
candy-mail uses sugar-async
for cancellation on SMTP sends via CancellationToken: an entry pre-check only
(isCancelled() before the conversation starts). Blocking I/O never yields to the event
loop mid-send, so onCancel() callbacks cannot fire during a running send — true
mid-flight cancellation would require an async transport (documented in
CALIBER_LEARNINGS.md).
Tests
cd candy-mail && vendor/bin/phpunit -c phpunit.xml
308 tests, 1,125 assertions (PHP 8.3, PHPUnit 10.5). The suite includes fork-based loopback fake-server tests for the SMTP conversation; no external network is ever dialled.
Accessor deprecation (2026-10-24)
Per the house bare-accessor convention (AGENTS.md: "Bare accessors (no get"), ruling 3b), Attachment::getContent() was renamed to its bare twin content() — the method slot was unclaimed (no withContent setter exists; the public readonly $content property names the raw backing field, not a callable, and PHP keeps property and method namespaces distinct). The legacy spelling stays a byte-identical one-line BC forwarder marked @deprecated with REMOVED-IN: 2.0.0; it will be removed in candy-mail v2.0.0.
| Namespace | Class | Deprecated | Use instead |
|---|---|---|---|
Mail |
Attachment |
getContent() |
content() |
Note the distinction the rename preserves: the readonly property $content is the raw nullable backing field, while content() resolves the bytes (including the defense-in-depth path re-read). tests/AccessorDeprecationTest.php pins BC identity on live objects from both factories, the property/method distinction, the forwarder shape, a discovery-first src/ census with an inverse leg that reddens any unmarked public get*(), and the README table agreement. Internal call sites in src/ (SmtpTransport, ResendTransport) were switched to the bare form in the same commit.
License
MIT
Credits & inspiration
Originally inspired by the Go Charm ecosystem; SugarCraft is developed as a native PHP project.