Upgrade to 1.0.0-rc833

Concerns: pushword/newsletter

A contact the site can only phone

newsletter_contact.email becomes nullable and gains a phone next to it, unique per audience like the address, with at least one of the two required. A person known only by a number — a booking taken over the phone, a client met on site — now has a row, where before they had none.

Run bin/console doctrine:schema:update --force after updating; the two new indexes and the relaxed column come from it.

Nothing changes for a base that only holds addresses. What changed under an unchanged call, for everyone:

  • subscribed and mailable are two questions now. Everything that sends — campaign arming, automation enrollment, the counts shown before Send, estimatedRecipients — asks the second, Contact::isMailable(): subscribed and holding an address. The API's audience payload gained a mailable count next to the per-status ones, and each contact a mailable flag.
  • ContactManager::subscribe() takes a nullable $email and a trailing $phone. Callers passing an address positionally are unaffected.
  • ContactRepository::findAllByEmail() accepts null and answers nothing for it, as findSubscribedSiblings() now does for a contact with no address. There is no sibling relation between two rows that have no address — the address is what makes them the same person.
  • Contact::$email is ?string. Code reading it straight into a string — a custom template, a listener — needs a null guard. Contact::identifier() gives the address, else the number, else #<id>.

The public subscribe form stays email-only. A number reaches the base over the API or through the admin's Opt in a contact, and a contact keyed on one alone is subscribed without a confirmation mail — there is none to send. source records who entered it, which is the evidence that opt-in owes.

Segments gained email and phone, both taking isSet / isNotSet, so "everybody I can only phone" is a rule you can write.

Both identifiers are now declared to the validator as well as to the schema, so writing one that another contact of the same audience already holds comes back as a 409 from the contact upsert and a validation error from PATCH and the admin — where it previously reached the driver as an integrity violation. The two rows are not joined: merging two consent records is a deliberate operation, and the bundle does not have one.

A page automation no longer mails one article once per language

An automation on the page source produces one campaign per page per step, and every one of them was addressed to the automation's single recipientWhen. On a site whose article exists in several locale versions — seventeen pages, one per host — that meant seventeen campaigns each broadcast to the whole audience, and every reader receiving the same article once per language.

Each campaign's segment is now narrowed by the locale of the page that triggered it, ANDed onto recipientWhen rather than replacing it. The narrowing only happens once the audience actually holds contacts in more than one locale, so a single-language list is unaffected and nothing has to be configured.

If you had worked around this with one automation per language, each carrying [{"field":"locale","op":"=","value":"xx"}] in recipientWhen, they keep working unchanged — the condition is simply now redundant.

A campaign carries one body per locale

Campaign gains a translations JSON column: the same campaign per locale, {"de": {"subject", "preheader", "bodyMarkdown"}}, edited under a Languages fieldset and readable and writable over the API, where PATCH merges per locale and a locale set to null drops it.

Nothing changes for a campaign that has none — it keeps sending its own text to everybody. What changed under an unchanged call:

  • NewsletterMailer::sendCampaign() resolves the body per contact, through the contact's locale, then its language part (de-ch reads de), then the campaign's own text. A campaign with no translations resolves to exactly what it sent before.
  • NewsletterMailer::sendTest() takes a trailing $locale, defaulting to the audience host's own — so the signature is compatible with existing calls.

An audience spanning several locale hosts can now be reached in one broadcast rather than one campaign per language, which is also what stops a bilingual reader being mailed twice.

The bounce mailbox can live on a remote IMAP server

pw:newsletter:bounces could only read a maildir on the filesystem, which assumes the app and its mail share a host. With the app on a VPS or in a container and the mail at a provider, the envelope sender's mailbox is reachable by IMAP and by nothing else — so nothing read it, dead addresses stayed subscribed, and every campaign retried them.

composer require webklex/php-imap
newsletter:
  bounce_imap_dsn: '%env(NEWSLETTER_BOUNCE_IMAP_DSN)%'
NEWSLETTER_BOUNCE_IMAP_DSN=imaps://bounce%40example.com:secret@imap.example.com:993/INBOX

Set bounce_maildir or bounce_imap_dsn, never both: the command stops with a message saying so. Percent-encode the credentials. The mailbox must still be one nothing else reads, since anything marking a message seen takes it out of the command's reach.

Nothing changes for a site reading a maildir. Two things changed for anyone calling the service directly:

  • BounceCollector::collect() takes a BounceSource, not a path. Ask BounceCollector::source($maildirOverride, $dryRun) for one — it resolves the configuration and says what to set when the answer is neither or both.
  • The mutual exclusion is checked when the command runs, not when the container builds: an %env()% DSN is still an unresolved placeholder at compile time, so a build-time rule would read it as set whatever the environment holds.

--notify=ops@example.com mails the summary, and only when something moved — at least one address dropped or one permanent failure recorded. The command runs four times an hour, and a recap on every one of them trains its reader to filter it. --dry-run never mails.