mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 10:04:18 +00:00
Add POP3 accounts
A protocol choice on the account form, a POP3 connection written over a socket, and a syncer that hands the RETR bytes to the same parser the IMAP path uses. Squashed from #291: the original commit and its five fixes (the Inbox binding, the label repairs, the import marker).
This commit is contained in:
1 parent
797ccc7ce2
commit
0dcbbeba42
28 files changed
+2832
-24
No files matched your search
@@ -11,10 +11,59 @@ import { Controller } from "@hotwired/stimulus";
|
||||
* the whole table is a couple of kilobytes, so no round-trip is needed.
|
||||
*/
|
||||
export default class extends Controller {
|
||||
static targets = ["note"];
|
||||
static targets = ["note", "incomingLabel", "portHint", "pop3Options"];
|
||||
|
||||
/**
|
||||
* What each incoming protocol calls itself in the form.
|
||||
*
|
||||
* The host field is one column whatever is on the other end — see
|
||||
* Account::$imapHost — so the label is the only thing on screen that says
|
||||
* which protocol this account is being set up for, and it has to move when
|
||||
* the radio does.
|
||||
*/
|
||||
static PROTOCOLS = {
|
||||
imap: { label: "IMAP host", port: "993" },
|
||||
pop3: { label: "POP3 host", port: "995" },
|
||||
};
|
||||
|
||||
connect() {
|
||||
this.presets = this._readPresets();
|
||||
this.protocolChanged();
|
||||
}
|
||||
|
||||
/**
|
||||
* The incoming-protocol radio was changed.
|
||||
*
|
||||
* Three things follow, and all three are the same fact stated three ways:
|
||||
* the host field is renamed, its port placeholder follows, and the POP3
|
||||
* delete option appears only where it means something. Nothing already
|
||||
* typed is cleared — switching back and forth should not cost the user
|
||||
* their host name.
|
||||
*/
|
||||
protocolChanged() {
|
||||
const protocol = this._currentProtocol();
|
||||
const copy = this.constructor.PROTOCOLS[protocol] ?? this.constructor.PROTOCOLS.imap;
|
||||
|
||||
if (this.hasIncomingLabelTarget) {
|
||||
this.incomingLabelTarget.textContent = copy.label;
|
||||
}
|
||||
|
||||
const port = this._field("imapPort");
|
||||
|
||||
if (port !== null) {
|
||||
port.placeholder = copy.port;
|
||||
}
|
||||
|
||||
if (this.hasPop3OptionsTarget) {
|
||||
this.pop3OptionsTarget.classList.toggle("hidden", protocol !== "pop3");
|
||||
}
|
||||
}
|
||||
|
||||
/** Which radio is checked, defaulting to IMAP like the entity does. */
|
||||
_currentProtocol() {
|
||||
const checked = this.element.querySelector('[name$="[incomingProtocol]"]:checked');
|
||||
|
||||
return checked === null ? "imap" : checked.value;
|
||||
}
|
||||
|
||||
/** Explicit choice from the dropdown — always wins. */
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Record which protocol an account's incoming mail arrives over (#NNN).
|
||||
*
|
||||
* One column, defaulted to `imap` in the database. Every row that exists
|
||||
* today is IMAP, Gmail or Microsoft: Gmail and Microsoft never open this
|
||||
* connection, and every other account is a plain IMAP one. So the default is
|
||||
* not a guess to be corrected later — it is the fact, and it means no
|
||||
* backfill and no window in which a running instance reads a null it cannot
|
||||
* interpret.
|
||||
*
|
||||
* Nothing is added for the POP3 delete mode or the UIDL ledger; both live in
|
||||
* the existing `account.settings` jsonb bag, which is where per-account
|
||||
* options have gone since it existed. See Account::SETTING_POP3_DELETE_MODE.
|
||||
*/
|
||||
final class Version20261021120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add account.incoming_protocol, defaulting to imap for every existing account';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql("ALTER TABLE account ADD incoming_protocol VARCHAR(10) DEFAULT 'imap' NOT NULL");
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE account DROP incoming_protocol');
|
||||
}
|
||||
}
|
||||
@@ -105,8 +105,8 @@ final class AccountFoldersController extends AbstractController
|
||||
|
||||
private function assertSyncsOverImap(Account $account): void
|
||||
{
|
||||
if (true === $account->isGmail() || true === $account->isMicrosoft()) {
|
||||
throw $this->createNotFoundException('This account syncs over its provider API; it has no IMAP folders to choose from.');
|
||||
if (false === $account->isPlainImap()) {
|
||||
throw $this->createNotFoundException('This account has no IMAP folders to choose from: it syncs over a provider API or over POP3.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Enum\Account;
|
||||
|
||||
/**
|
||||
* How an account's incoming mail is read.
|
||||
*
|
||||
* The two protocols are not variants of one thing and this enum is the place
|
||||
* that says so. IMAP names messages by UID, keeps them in folders and lets the
|
||||
* client change what is on the server; POP3 numbers messages by position, has
|
||||
* exactly one mailbox, and the only destructive thing it can do is delete.
|
||||
* Everything downstream — the syncer, the folder listing, the flag
|
||||
* reconciler, the Sent copy — reads this and takes a different road or none
|
||||
* at all.
|
||||
*
|
||||
* Stored in its own column rather than inferred from the host. Misk is the
|
||||
* account that made this a column: it serves both protocols on the same
|
||||
* domain, so there is nothing about `mail.example.com:995` that says which one
|
||||
* is on the other end.
|
||||
*/
|
||||
enum IncomingProtocol: string
|
||||
{
|
||||
case Imap = 'imap';
|
||||
case Pop3 = 'pop3';
|
||||
|
||||
/**
|
||||
* Whether the provider's server can describe a folder tree, hold flags and
|
||||
* accept changes pushed back at it.
|
||||
*
|
||||
* The whole of what "plain IMAP" was used for before this enum existed.
|
||||
* POP3 has none of it, which is why so many call sites read this rather
|
||||
* than the protocol itself.
|
||||
*/
|
||||
public function isImap(): bool
|
||||
{
|
||||
return self::Imap === $this;
|
||||
}
|
||||
|
||||
public function isPop3(): bool
|
||||
{
|
||||
return self::Pop3 === $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* The port clients use when the form has not been told one.
|
||||
*
|
||||
* POP3's only encrypted port. There is no plaintext equivalent offered:
|
||||
* the form's encryption choices are the same list for both protocols and
|
||||
* "None" is what a server on 110 would need, so that stays available —
|
||||
* this is a default, not a restriction.
|
||||
*/
|
||||
public function defaultPort(): int
|
||||
{
|
||||
return self::Pop3 === $this ? 995 : 993;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Enum\Account;
|
||||
|
||||
/**
|
||||
* What a POP3 sync does with a message once it is safely stored here.
|
||||
*
|
||||
* Three answers, and the default is the one that cannot lose mail. A POP3
|
||||
* server has one mailbox and no concept of a folder: there is nowhere for a
|
||||
* message to go, so "keep it" leaves it exactly where it is and a later sync
|
||||
* sees it again, recognises it by its UIDL, and skips it. Nothing is read
|
||||
* twice, nothing is lost, and the server's copy is the backup.
|
||||
*
|
||||
* That is not what most POP3 clients do. Thunderbird and Outlook delete after
|
||||
* fetching by default, which keeps the server mailbox small and is the reason
|
||||
* POP3 has a reputation for mail that exists in exactly one place. This
|
||||
* default is the other way round on purpose: plMail already holds the
|
||||
* message, so a deletion buys the operator very little and costs them the only
|
||||
* second copy.
|
||||
*
|
||||
* Deleting is per-account and opt-in. See Pop3AccountSyncer for where it is
|
||||
* honoured, and note that it is only ever asked for after the message's
|
||||
* transaction has committed — a sync that fails to store a message must never
|
||||
* have told the server to forget it.
|
||||
*/
|
||||
enum Pop3DeleteMode: string
|
||||
{
|
||||
/** Leave every message on the server; a re-fetch recognises it by UIDL. */
|
||||
case Keep = 'keep';
|
||||
|
||||
/**
|
||||
* Delete after a successful store — RETR's implicit delete, done
|
||||
* explicitly so it is not left to a client's interpretation.
|
||||
*/
|
||||
case Delete = 'delete';
|
||||
|
||||
/**
|
||||
* Delete once the message has been seen here AND its transaction
|
||||
* committed, which is what "Delete" does. Named separately because the
|
||||
* setting is read by a person, and "delete after fetch" is the sentence
|
||||
* that describes it; the enum keeps both spellings apart so a future
|
||||
* "delete when read in the UI" is not mistaken for this one.
|
||||
*/
|
||||
public function deletes(): bool
|
||||
{
|
||||
return self::Delete === $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Anything unrecognised reads as Keep.
|
||||
*
|
||||
* The value lives in a jsonb settings bag, which a restored backup or a
|
||||
* hand edit can put anything into. Defaulting to the destructive answer on
|
||||
* a value nobody recognises is the one failure this setting must not have.
|
||||
*/
|
||||
public static function fromSetting(mixed $value): self
|
||||
{
|
||||
// `tryFrom`, not a comparison against the case: the value in the bag
|
||||
// is the enum's backing STRING ('delete'), and `self::Delete ===
|
||||
// 'delete'` is false — a comparison between a case and a string that
|
||||
// looks like it should work and silently means Keep for every account
|
||||
// that asked for Delete. tryFrom() answers null for anything it does
|
||||
// not know, which is the same answer as any other junk.
|
||||
return self::tryFrom(is_string($value) ? $value : '') ?? self::Keep;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Interface;
|
||||
|
||||
/**
|
||||
* A connection that a handler deadline may shut down from underneath its
|
||||
* reader.
|
||||
*
|
||||
* ImapDeadline's whole mechanism is "close the socket and let the code that
|
||||
* was reading it fail the way it always has". A webklex Client hands the
|
||||
* deadline a stream to shut down. A POP3 session is not a client and has no
|
||||
* getConnection(); this is the two-method shape that lets one alarm cover
|
||||
* both without the deadline knowing which protocol it is dealing with.
|
||||
*
|
||||
* Deliberately not an exception path. closeForDeadline() must not throw and
|
||||
* must leave the connection in a state where every later read fails normally
|
||||
* rather than repairing itself — see ImapDeadline's docblock for why closing
|
||||
* a socket is the effect and throwing from the signal handler is not.
|
||||
*/
|
||||
interface DeadlineClosable
|
||||
{
|
||||
/**
|
||||
* Shut the connection down so the reader in progress fails at once.
|
||||
*
|
||||
* Shut down rather than closed outright: the class's own `finally` will
|
||||
* still try to close it, and some servers have a goodbye to read. Mirrors
|
||||
* ImapDeadline::expire(), which uses STREAM_SHUT_RDWR for the same reason.
|
||||
*/
|
||||
public function closeForDeadline(): void;
|
||||
|
||||
/** Whether there is still something to shut down. */
|
||||
public function isOpen(): bool;
|
||||
}
|
||||
@@ -4,7 +4,9 @@ namespace App\Entity\Mail;
|
||||
|
||||
use App\Domain\Enum\Account\AuthType;
|
||||
use App\Domain\Enum\Account\EmailAliasStatus;
|
||||
use App\Domain\Enum\Account\IncomingProtocol;
|
||||
use App\Domain\Enum\Account\MailProvider;
|
||||
use App\Domain\Enum\Account\Pop3DeleteMode;
|
||||
use App\Domain\Model\AccountModel;
|
||||
use App\Entity\User\User;
|
||||
use App\Infrastructure\Doctrine\Type\EncryptedStringType;
|
||||
@@ -81,6 +83,33 @@ class Account extends AccountModel
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
public ?string $email = null;
|
||||
|
||||
/**
|
||||
* How this account's incoming mail is read.
|
||||
*
|
||||
* A column of its own since POP3 was added, rather than a fourth value on
|
||||
* $authType: authType answers "how are the credentials presented", and a
|
||||
* POP3 account presents a password exactly as an IMAP one does. What
|
||||
* changes is the protocol underneath, and everything downstream that
|
||||
* reads this belongs to one protocol or the other.
|
||||
*
|
||||
* Defaults to IMAP at the database level so every row that existed before
|
||||
* the column — all of them IMAP, Gmail or Graph — reads as what it is
|
||||
* without a backfill. Gmail and Microsoft accounts are API-synced and
|
||||
* never open this connection; they carry Imap because it predates the
|
||||
* column and nothing reads it for them.
|
||||
*/
|
||||
#[ORM\Column(length: 10, options: ['default' => IncomingProtocol::Imap->value], enumType: IncomingProtocol::class)]
|
||||
public IncomingProtocol $incomingProtocol = IncomingProtocol::Imap;
|
||||
|
||||
/**
|
||||
* The incoming server, whichever protocol it speaks.
|
||||
*
|
||||
* Still called imapHost because the column is, and because renaming it
|
||||
* would touch every template, form field and export for a name that is
|
||||
* only ever read after $incomingProtocol has said what is on the other
|
||||
* end. The form labels it by protocol; nothing user-facing says "IMAP"
|
||||
* for a POP3 account.
|
||||
*/
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
public ?string $imapHost = null;
|
||||
|
||||
@@ -490,6 +519,36 @@ class Account extends AccountModel
|
||||
/** Where a backfill listing that is under way has got to. See $backfillPageToken. */
|
||||
public const string SETTING_BACKFILL_PAGE_TOKEN = 'sync.backfill_page_token';
|
||||
|
||||
/**
|
||||
* What a POP3 account does with a message once it is stored here. See
|
||||
* $pop3DeleteMode.
|
||||
*/
|
||||
public const string SETTING_POP3_DELETE_MODE = 'sync.pop3.delete_mode';
|
||||
|
||||
/**
|
||||
* The UIDLs this account has already stored, newest last.
|
||||
*
|
||||
* POP3 has no sync cursor and no folder to hold a high-water mark, so this
|
||||
* is what a sync compares the server's UIDL list against. Only the tail is
|
||||
* kept — see $pop3SeenUidls for the cap and the reasoning — because a
|
||||
* message that has not been seen in the last few thousand is either gone
|
||||
* from the server or so old the server will never offer it again.
|
||||
*/
|
||||
public const string SETTING_POP3_SEEN_UIDLS = 'sync.pop3.seen_uidls';
|
||||
|
||||
/**
|
||||
* How many UIDLs $pop3SeenUidls remembers.
|
||||
*
|
||||
* The mailbox this exists for holds a few thousand messages, and a UIDL is
|
||||
* about ten bytes: the whole list is tens of kilobytes of jsonb. The cap
|
||||
* is there so an account that keeps everything on a large server does not
|
||||
* grow a settings blob without a bound. A message older than this many
|
||||
* syncs back is one the server has already given up offering, since a
|
||||
* POP3 server lists its whole mailbox every time — there is no window to
|
||||
* miss.
|
||||
*/
|
||||
public const int POP3_UIDL_MEMORY = 5000;
|
||||
|
||||
/** Unfetched messages the listing under way has found so far. */
|
||||
public const string SETTING_BACKFILL_PENDING = 'sync.backfill_pending';
|
||||
|
||||
@@ -703,6 +762,34 @@ class Account extends AccountModel
|
||||
* question about $authType and $oauthProvider, which is an interpretation
|
||||
* of two strings rather than the plain read $isActive and $pushEnabled are.
|
||||
*/
|
||||
/**
|
||||
* Whether this account's mail is read from a POP3 server.
|
||||
*
|
||||
* The counterpart of isGmail()/isMicrosoft(), and deliberately shaped the
|
||||
* same way: a question about stored state that every caller may ask
|
||||
* without opening anything. Unlike those two it can be true at the same
|
||||
* time as neither — a plain IMAP account answers false to all three.
|
||||
*/
|
||||
public function isPop3(): bool
|
||||
{
|
||||
return $this->incomingProtocol->isPop3();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this account speaks IMAP and no API — the road that has
|
||||
* folders, flags and a folder tree to read.
|
||||
*
|
||||
* The one predicate that replaces "not Gmail and not Microsoft" wherever
|
||||
* the code then goes on to open an IMAP connection. POP3 accounts are
|
||||
* excluded here and handled by their own syncer; see Pop3AccountSyncer.
|
||||
*/
|
||||
public function isPlainImap(): bool
|
||||
{
|
||||
return false === $this->isGmail()
|
||||
&& false === $this->isMicrosoft()
|
||||
&& false === $this->isPop3();
|
||||
}
|
||||
|
||||
public function isMicrosoft(): bool
|
||||
{
|
||||
if (AuthType::OAuth2->value !== $this->authType) {
|
||||
@@ -948,6 +1035,90 @@ class Account extends AccountModel
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a POP3 sync does with a message it has just stored.
|
||||
*
|
||||
* Virtual on the jsonb bag, like $backfillTarget, and read through
|
||||
* Pop3DeleteMode::fromSetting() so an unrecognised value — a restored
|
||||
* backup, a hand edit, a value from a future version — reads as Keep
|
||||
* rather than as Delete. That is the one direction this must fail in.
|
||||
*/
|
||||
public Pop3DeleteMode $pop3DeleteMode {
|
||||
get => Pop3DeleteMode::fromSetting($this->getSetting(self::SETTING_POP3_DELETE_MODE));
|
||||
set (Pop3DeleteMode $mode) {
|
||||
$this->setSetting(self::SETTING_POP3_DELETE_MODE, $mode->value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The UIDLs this account has stored, oldest first.
|
||||
*
|
||||
* A list and not a set because the order is what makes the cap in
|
||||
* $pop3SeenUidls possible; lookup by UIDL is a linear scan of a list
|
||||
* that is bounded and small, and the alternative — a hash keyed by UIDL —
|
||||
* has no stable order to drop from when it fills.
|
||||
*
|
||||
* Filtered on the way out as well as in: the bag is jsonb, and a value
|
||||
* that is not a list of strings must read as empty rather than reach a
|
||||
* comparison. An empty list is also the correct answer for an account
|
||||
* that has never synced, and means "nothing stored yet" — not "everything
|
||||
* has".
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public array $pop3SeenUidls {
|
||||
get {
|
||||
$stored = $this->getSetting(self::SETTING_POP3_SEEN_UIDLS, []);
|
||||
|
||||
if (false === is_array($stored)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter($stored, static fn (mixed $uidl): bool => true === is_string($uidl) && '' !== $uidl));
|
||||
}
|
||||
set {
|
||||
// Deduplicated and capped from the END: newest last, so what
|
||||
// survives is the recent history, which is what a sync compares
|
||||
// against. array_values() first because array_slice on a list is
|
||||
// the only shape that survives json encoding as a list — a JSON
|
||||
// object with numeric keys would round-trip as one and never read
|
||||
// back as the list this promises.
|
||||
$uidls = array_values(array_unique($value));
|
||||
|
||||
if (count($uidls) > self::POP3_UIDL_MEMORY) {
|
||||
$uidls = array_slice($uidls, -self::POP3_UIDL_MEMORY);
|
||||
}
|
||||
|
||||
$this->setSetting(self::SETTING_POP3_SEEN_UIDLS, $uidls);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record UIDLs a sync has just stored, keeping the list under the cap.
|
||||
*
|
||||
* The writer a sync uses, so the cap and the deduplication live here and
|
||||
* not in Pop3AccountSyncer. Appending rather than replacing is what lets a
|
||||
* sync that stored ten messages add ten to a list of thousands without
|
||||
* reading them all out and writing them back — though it does read them to
|
||||
* put them back, because a jsonb value is written whole either way.
|
||||
*
|
||||
* @param list<string> $uidls
|
||||
*/
|
||||
public function rememberPop3Uidls(array $uidls): void
|
||||
{
|
||||
if ([] === $uidls) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->pop3SeenUidls = [...$this->pop3SeenUidls, ...$uidls];
|
||||
}
|
||||
|
||||
/** Whether this POP3 account has already stored a message with this UIDL. */
|
||||
public function hasStoredPop3Uidl(string $uidl): bool
|
||||
{
|
||||
return in_array($uidl, $this->pop3SeenUidls, true);
|
||||
}
|
||||
|
||||
/** Whether a Microsoft account has folders whose history is still to be read. */
|
||||
public function needsGraphImport(): bool
|
||||
{
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Domain\Enum\Account\IncomingProtocol;
|
||||
use App\Domain\Enum\Account\Pop3DeleteMode;
|
||||
use App\Domain\Helper\MailServerHost;
|
||||
use App\Entity\Mail\Account;
|
||||
use App\Service\Mail\MailPresetProvider;
|
||||
@@ -70,6 +72,51 @@ class AccountType extends AbstractType
|
||||
'label' => 'Password',
|
||||
'required' => $options['require_password'],
|
||||
])
|
||||
// ── Which protocol the incoming half speaks ────────────────────
|
||||
// Above the host fields, because it decides what they are called
|
||||
// and what port they start on. Not mapped to a setting but to the
|
||||
// column itself: this is what every syncer branches on.
|
||||
//
|
||||
// The choices are a plain radio pair rather than a dropdown. There
|
||||
// are two, they are mutually exclusive, and the one that is not
|
||||
// the default is the whole reason somebody opens this form.
|
||||
->add('incomingProtocol', ChoiceType::class, [
|
||||
'choices' => [
|
||||
'account.form.protocol.imap' => IncomingProtocol::Imap,
|
||||
'account.form.protocol.pop3' => IncomingProtocol::Pop3,
|
||||
],
|
||||
'choice_value' => static fn (?IncomingProtocol $protocol): string => $protocol->value ?? '',
|
||||
'expanded' => true,
|
||||
'multiple' => false,
|
||||
'required' => true,
|
||||
// No radio checked and none submitted reads as IMAP, which is
|
||||
// what the column's default says an account without an answer
|
||||
// is. A string, because ChoiceType casts empty_data to one
|
||||
// before matching it against the choice values.
|
||||
'empty_data' => IncomingProtocol::Imap->value,
|
||||
'label' => 'account.form.protocol.label',
|
||||
'attr' => [
|
||||
'class' => 'form-radio-group',
|
||||
'data-settings--imap-preset-target' => 'protocol',
|
||||
'data-action' => 'change->settings--imap-preset#protocolChanged',
|
||||
],
|
||||
])
|
||||
->add('pop3DeleteMode', ChoiceType::class, [
|
||||
// Rendered only for a POP3 account; the template hides it for
|
||||
// an IMAP one. Kept on the form either way so a submitted form
|
||||
// — which includes hidden fields — round-trips without
|
||||
// resetting a choice the user made before switching protocol.
|
||||
'choices' => [
|
||||
'account.form.pop3.delete_on_fetch' => Pop3DeleteMode::Delete,
|
||||
'account.form.pop3.keep_on_server' => Pop3DeleteMode::Keep,
|
||||
],
|
||||
'choice_value' => static fn (?Pop3DeleteMode $mode): string => $mode->value ?? '',
|
||||
'mapped' => false,
|
||||
'required' => false,
|
||||
'label' => 'account.form.pop3.delete_label',
|
||||
'help' => 'account.form.pop3.delete_help',
|
||||
'attr' => ['class' => 'form-select'],
|
||||
])
|
||||
->add('imapHost', TextType::class, [
|
||||
'attr' => [
|
||||
'placeholder' => 'imap.example.com',
|
||||
@@ -80,10 +127,14 @@ class AccountType extends AbstractType
|
||||
])
|
||||
->add('imapPort', IntegerType::class, [
|
||||
'attr' => [
|
||||
'placeholder' => '993',
|
||||
'class' => 'form-input',
|
||||
// Both ports, because the placeholder has to say the same
|
||||
// thing whichever protocol is chosen; the Stimulus
|
||||
// controller swaps it when the radio changes. A POP3
|
||||
// server that wants 110 is typed in, not offered.
|
||||
'placeholder' => '993 / 995',
|
||||
'class' => 'form-input',
|
||||
],
|
||||
'label' => 'IMAP port',
|
||||
'label' => 'account.form.imap.port',
|
||||
'constraints' => [new NotBlank(), new Range(min: 1, max: 65535)],
|
||||
])
|
||||
->add('imapEncryption', ChoiceType::class, [
|
||||
@@ -151,6 +202,34 @@ class AccountType extends AbstractType
|
||||
'required' => false,
|
||||
]);
|
||||
|
||||
// ── The POP3 delete mode: a form field, a jsonb setting ────────────
|
||||
// Not a column, so it cannot be mapped. The value lives in the
|
||||
// account's settings bag behind Account::$pop3DeleteMode, and this is
|
||||
// the one writer. Filled from the entity on the way in and written
|
||||
// back on the way out, so the form and the bag never disagree about
|
||||
// which of the two the user last chose.
|
||||
$builder->addEventListener(FormEvents::POST_SET_DATA, static function (FormEvent $event): void {
|
||||
$account = $event->getData();
|
||||
|
||||
if ($account instanceof Account) {
|
||||
$event->getForm()->get('pop3DeleteMode')->setData($account->pop3DeleteMode);
|
||||
}
|
||||
});
|
||||
|
||||
$builder->addEventListener(FormEvents::POST_SUBMIT, static function (FormEvent $event): void {
|
||||
$account = $event->getData();
|
||||
|
||||
if (false === $account instanceof Account) {
|
||||
return;
|
||||
}
|
||||
|
||||
$chosen = $event->getForm()->get('pop3DeleteMode')->getData();
|
||||
|
||||
// Unrecognised reads as Keep, which is Pop3DeleteMode's own rule
|
||||
// and not repeated here.
|
||||
$account->pop3DeleteMode = Pop3DeleteMode::fromSetting($chosen);
|
||||
});
|
||||
|
||||
// What the account had before the form wrote on it: `always_empty`
|
||||
// submits a blank for an untouched field, and by POST_SUBMIT that
|
||||
// blank is already on the entity.
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Pop3;
|
||||
|
||||
use App\Domain\Interface\DeadlineClosable;
|
||||
use App\Service\Imap\ImapDeadline;
|
||||
|
||||
/**
|
||||
* A POP3 conversation, written out rather than borrowed.
|
||||
*
|
||||
* ── Why not a library ────────────────────────────────────────────────────────
|
||||
* The IMAP side uses webklex/php-imap, which is a large and well-tested MIME
|
||||
* parser behind a thin protocol client. There is no equivalent for POP3:
|
||||
* the runtimes on Packagist are thin clients of their own, and the PHP
|
||||
* `imap` extension — which can do POP3 — is not in the image (the Dockerfile
|
||||
* installs neither `imap` nor `imap-ssl`, and webklex reads IMAP over a raw
|
||||
* socket precisely so it does not have to be). Adding an extension means
|
||||
* rebuilding the image on two architectures for a protocol whose command set
|
||||
* is fifteen verbs.
|
||||
*
|
||||
* But the part that actually matters is not the verbs. It is what comes back
|
||||
* after RETR: a full RFC 822 message that has to be parsed exactly as an IMAP
|
||||
* fetch is. That already exists — webklex's Message::fromString() — and
|
||||
* reusing it is the whole reason this class stops at "here are the bytes".
|
||||
* See Pop3AccountSyncer for the handover.
|
||||
*
|
||||
* ── What is guaranteed, and what is not ──────────────────────────────────────
|
||||
* The connection is TLS for every port in the form's default list, cert
|
||||
* verification is always on (matching the IMAP side, which sets
|
||||
* validate_cert => true unconditionally — and which is why Proton Bridge's
|
||||
* self-signed certificate fails there too), and every response is checked
|
||||
* against +OK/-ERR rather than assumed.
|
||||
*
|
||||
* Nothing here has retry logic. A dropped socket is the syncer's problem, and
|
||||
* its answer is to leave the mailbox's high-water mark where it was so the
|
||||
* next sync asks again.
|
||||
*
|
||||
* ── Sessions and the delete mark ─────────────────────────────────────────────
|
||||
* POP3 has a transaction state that lasts one connection: DELE marks a
|
||||
* message, and QUIT is what makes the marks real. A session that ends any
|
||||
* other way rolls them back, which is exactly the failure behaviour wanted —
|
||||
* a sync that dies half way through deleting must not have deleted anything.
|
||||
* So this class keeps the marks and sends them only from quit().
|
||||
*/
|
||||
final class Pop3Connection implements DeadlineClosable
|
||||
{
|
||||
/** How long a single read may block before it is treated as a dropped server. */
|
||||
private const int TIMEOUT_SECONDS = 60;
|
||||
|
||||
/** @var resource|null */
|
||||
private $stream = null;
|
||||
|
||||
/** @var array<string, true> UIDLs marked for deletion this session */
|
||||
private array $marked = [];
|
||||
|
||||
/**
|
||||
* Held only until open() has watched the session with it; see the call
|
||||
* there. Not kept afterwards: the deadline's list is weak, so a session
|
||||
* that kept it would be keeping nothing but a reference to a service.
|
||||
*/
|
||||
private function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Open and authenticate.
|
||||
*
|
||||
* $encryption takes the form's own vocabulary — ssl, starttls, none —
|
||||
* because a POP3 account shares the account form with an IMAP one and
|
||||
* nothing is gained by translating at the call site. `ssl` is implicit TLS
|
||||
* (port 995), `starttls` upgrades in-band (port 110), `none` is plaintext.
|
||||
*
|
||||
* @throws Pop3Exception when the server refuses the connection, the
|
||||
* certificate, or the credentials
|
||||
*/
|
||||
public static function open(
|
||||
string $host,
|
||||
int $port,
|
||||
?string $encryption,
|
||||
string $username,
|
||||
string $password,
|
||||
ImapDeadline $deadline,
|
||||
): self {
|
||||
$connection = new self();
|
||||
|
||||
$connection->stream = $connection->dial($host, $port, $encryption);
|
||||
|
||||
// The deadline closes a connection that outlives its handler. POP3
|
||||
// sessions are short — a greeting, a few hundred commands, QUIT — so
|
||||
// the alarm mostly matters for a server that accepts the socket and
|
||||
// then says nothing, which is exactly the stall that has no read
|
||||
// timeout to catch it.
|
||||
$deadline->watchSession($connection);
|
||||
|
||||
try {
|
||||
$connection->expect('+OK');
|
||||
|
||||
if ('starttls' === $encryption) {
|
||||
$connection->command('STLS', '+OK');
|
||||
$connection->enableTls($host);
|
||||
}
|
||||
|
||||
// USER/PASS rather than APOP. APOP needs the server's timestamp
|
||||
// and an MD5 digest, and RFC 5034's other options (SASL) are not
|
||||
// offered by the providers this exists for. Over TLS the password
|
||||
// is not on the wire in clear, which is the property USER/PASS
|
||||
// lacks on its own.
|
||||
$connection->command('USER ' . $username, '+OK');
|
||||
$connection->command('PASS ' . $password, '+OK');
|
||||
} catch (\Throwable $failure) {
|
||||
$connection->closeQuietly();
|
||||
|
||||
throw $failure;
|
||||
}
|
||||
|
||||
return $connection;
|
||||
}
|
||||
|
||||
/**
|
||||
* The mailbox as the server describes it: one line per message, in the
|
||||
* order it is numbered.
|
||||
*
|
||||
* @return list<Pop3Listing> in server order
|
||||
*
|
||||
* @throws Pop3Exception
|
||||
*/
|
||||
public function list(): array
|
||||
{
|
||||
// The count and the octet total ride on the +OK status line, which
|
||||
// command() has already consumed. What is left is the listing itself.
|
||||
$lines = $this->command('LIST', '+OK');
|
||||
|
||||
$listing = [];
|
||||
|
||||
foreach ($lines as $line) {
|
||||
// `1 215054` — the number, then the size on the server, which is
|
||||
// the size on the wire and not necessarily the size of the message
|
||||
// after decoding. Only the number is needed here; the size is kept
|
||||
// because a caller rebuilding a message can use it to sanity-check
|
||||
// what RETR returned.
|
||||
$parts = explode(' ', trim($line));
|
||||
|
||||
if (2 !== count($parts) || false === ctype_digit($parts[0])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$listing[] = new Pop3Listing((int) $parts[0], (int) $parts[1]);
|
||||
}
|
||||
|
||||
return $listing;
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's stable per-message identifier, keyed by message number.
|
||||
*
|
||||
* This is what makes keeping mail on the server workable. Message numbers
|
||||
* restart at 1 after a deletion, so the number a message had on the last
|
||||
* sync means nothing on this one; UIDL stays with the message for as long
|
||||
* as it exists (RFC 1939 §7), and is the only thing a re-fetch may be
|
||||
* recognised by.
|
||||
*
|
||||
* @return array<int, string> message number => UIDL
|
||||
*
|
||||
* @throws Pop3Exception
|
||||
*/
|
||||
public function uidl(): array
|
||||
{
|
||||
// Same shape as LIST: the +OK line is already gone. The count is not
|
||||
// needed here — the listing's own length is the answer.
|
||||
$lines = $this->command('UIDL', '+OK');
|
||||
|
||||
$ids = [];
|
||||
|
||||
foreach ($lines as $line) {
|
||||
// `1 AXgQYtzw` — a UIDL is up to 70 visible ASCII characters and
|
||||
// must not contain a space, so one split on the first space is
|
||||
// exact.
|
||||
$parts = explode(' ', trim($line), 2);
|
||||
|
||||
if (2 !== count($parts) || false === ctype_digit($parts[0]) || '' === $parts[1]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ids[(int) $parts[0]] = $parts[1];
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* The original bytes of one message, dot-unstuffed.
|
||||
*
|
||||
* The server's answer is `+OK <size> octets` followed by the message and a
|
||||
* line consisting of a single period. Two transformations RFC 1939 §3
|
||||
* requires the client to undo are done here: a line starting with a period
|
||||
* had one added before transmission, and the terminating period is not
|
||||
* part of the message.
|
||||
*
|
||||
* The bytes come back exactly as the sender wrote them — CRLF line
|
||||
* endings, transfer encodings intact — which is what the IMAP path hands
|
||||
* to Message::fromString() and therefore the only shape that parses the
|
||||
* same way.
|
||||
*
|
||||
* @throws Pop3Exception
|
||||
*/
|
||||
public function retrieve(int $number): string
|
||||
{
|
||||
$this->write('RETR ' . $number);
|
||||
|
||||
$reply = $this->readLine();
|
||||
|
||||
if (false === str_starts_with($reply, '+OK')) {
|
||||
throw new Pop3Exception(sprintf('The server refused to send message %d: %s', $number, $reply));
|
||||
}
|
||||
|
||||
$lines = [];
|
||||
|
||||
while (true) {
|
||||
$line = $this->readLine();
|
||||
|
||||
if ('.' === $line) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Dot-stuffing: every line the client is responsible for starting
|
||||
// with a period was prefixed with one on the wire.
|
||||
$lines[] = str_starts_with($line, '.') ? substr($line, 1) : $line;
|
||||
}
|
||||
|
||||
// Each line was read WITHOUT its CRLF — readLine() trims it — so the
|
||||
// message is rebuilt with one after every line including the last.
|
||||
// That is right for a header block and harmless for a body; what would
|
||||
// not be right is joining them without, which turns a multipart body
|
||||
// into one enormous line and makes every boundary invisible.
|
||||
return implode("\r\n", $lines) . "\r\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark one message for deletion, without deleting it yet.
|
||||
*
|
||||
* The mark is real to the server as soon as DELE returns OK, but it is
|
||||
* rolled back unless this session ends with QUIT. Keeping it here as well
|
||||
* is what lets a caller ask whether a message is already marked (a retry
|
||||
* within one session) and what makes the class's own contract — nothing is
|
||||
* deleted that quit() was not asked for — sayable.
|
||||
*
|
||||
* @throws Pop3Exception
|
||||
*/
|
||||
public function markDeleted(int $number, string $uidl): void
|
||||
{
|
||||
$this->command('DELE ' . $number, '+OK');
|
||||
|
||||
$this->marked[$uidl] = true;
|
||||
}
|
||||
|
||||
public function isMarkedDeleted(string $uidl): bool
|
||||
{
|
||||
return true === isset($this->marked[$uidl]);
|
||||
}
|
||||
|
||||
/**
|
||||
* End the session, which is what makes the marks real, and close.
|
||||
*
|
||||
* A session that has nothing marked still says QUIT: it is what tells the
|
||||
* server the client finished cleanly rather than vanished, and some servers
|
||||
* hold the mailbox locked until they hear it.
|
||||
*/
|
||||
public function quit(): void
|
||||
{
|
||||
if (null === $this->stream) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->command('QUIT', '+OK');
|
||||
} catch (Pop3Exception) {
|
||||
// The server hung up instead of answering QUIT. RFC 1939 allows it
|
||||
// and some do; the marks were sent with the command either way.
|
||||
} finally {
|
||||
$this->closeQuietly();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hang up without QUIT, which ROLLS BACK every deletion marked this
|
||||
* session.
|
||||
*
|
||||
* For the path that failed: a sync dies, and the messages it had only got
|
||||
* as far as marking stay on the server.
|
||||
*/
|
||||
public function abort(): void
|
||||
{
|
||||
$this->closeQuietly();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string> the lines after the status line, CRLF trimmed
|
||||
*
|
||||
* @throws Pop3Exception
|
||||
*/
|
||||
private function command(string $command, string $expect): array
|
||||
{
|
||||
$this->write($command);
|
||||
|
||||
$reply = $this->readLine();
|
||||
|
||||
if (false === str_starts_with($reply, $expect)) {
|
||||
throw new Pop3Exception(sprintf('The server refused %s: %s', strtok($command, ' '), $reply));
|
||||
}
|
||||
|
||||
$lines = [];
|
||||
|
||||
// A multi-line answer is announced by the status line alone; a
|
||||
// single-line one has nothing behind it and the next read would block
|
||||
// until the timeout. The distinction is made by the caller knowing
|
||||
// which of its commands are multi-line — LIST, UIDL and RETR are, and
|
||||
// nothing else in this class is.
|
||||
if (true === in_array(strtok($command, ' '), ['LIST', 'UIDL'], true)) {
|
||||
while (true) {
|
||||
$line = $this->readLine();
|
||||
|
||||
if ('.' === $line) {
|
||||
break;
|
||||
}
|
||||
|
||||
$lines[] = $line;
|
||||
}
|
||||
}
|
||||
|
||||
return $lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return resource
|
||||
*
|
||||
* @throws Pop3Exception
|
||||
*/
|
||||
private function dial(string $host, int $port, ?string $encryption)
|
||||
{
|
||||
$address = sprintf('tcp://%s:%d', $host, $port);
|
||||
|
||||
// The connection context is where certificate verification is asked
|
||||
// for. verify_peer and verify_peer_name are the two that matter and
|
||||
// both are on, with the system CA store — the same posture the IMAP
|
||||
// client takes.
|
||||
$context = stream_context_create([
|
||||
'ssl' => [
|
||||
'verify_peer' => true,
|
||||
'verify_peer_name' => true,
|
||||
'peer_name' => $host,
|
||||
'SNI_enabled' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
$stream = @stream_socket_client(
|
||||
$address,
|
||||
$errno,
|
||||
$error,
|
||||
self::TIMEOUT_SECONDS,
|
||||
STREAM_CLIENT_CONNECT,
|
||||
$context,
|
||||
);
|
||||
|
||||
if (false === $stream) {
|
||||
throw new Pop3Exception(sprintf(
|
||||
'Could not reach %s: %s',
|
||||
$address,
|
||||
'' !== $error ? $error : ('socket error ' . $errno),
|
||||
));
|
||||
}
|
||||
|
||||
stream_set_timeout($stream, self::TIMEOUT_SECONDS);
|
||||
|
||||
// Implicit TLS is a socket option rather than a handshake after the
|
||||
// fact: `ssl://` in the address would work too and does the same
|
||||
// thing, but taking it here keeps the plaintext and STARTTLS paths
|
||||
// sharing one dial.
|
||||
if ('ssl' === $encryption) {
|
||||
$this->stream = $stream;
|
||||
$this->enableTls($host);
|
||||
|
||||
return $this->stream;
|
||||
}
|
||||
|
||||
return $stream;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Pop3Exception
|
||||
*/
|
||||
private function enableTls(string $host): void
|
||||
{
|
||||
$stream = $this->stream;
|
||||
|
||||
if (null === $stream) {
|
||||
throw new Pop3Exception('Cannot start TLS on a connection that is not open.');
|
||||
}
|
||||
|
||||
$method = STREAM_CRYPTO_METHOD_TLS_CLIENT;
|
||||
|
||||
if (defined('STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT')) {
|
||||
$method |= STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT;
|
||||
}
|
||||
|
||||
if (defined('STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT')) {
|
||||
$method |= STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT;
|
||||
}
|
||||
|
||||
// The peer name is re-stated because crypto_enable() reuses the
|
||||
// context the socket was opened with, and the verification options
|
||||
// travel with the context rather than the stream.
|
||||
if (false === @stream_socket_enable_crypto($stream, true, $method)) {
|
||||
throw new Pop3Exception(sprintf('The TLS handshake with %s failed.', $host));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Pop3Exception
|
||||
*/
|
||||
private function write(string $command): void
|
||||
{
|
||||
$stream = $this->stream;
|
||||
|
||||
if (null === $stream) {
|
||||
throw new Pop3Exception('The POP3 connection is not open.');
|
||||
}
|
||||
|
||||
if (false === @fwrite($stream, $command . "\r\n")) {
|
||||
throw new Pop3Exception('The connection dropped while sending a command.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Pop3Exception
|
||||
*/
|
||||
private function readLine(): string
|
||||
{
|
||||
$stream = $this->stream;
|
||||
|
||||
if (null === $stream) {
|
||||
throw new Pop3Exception('The POP3 connection is not open.');
|
||||
}
|
||||
|
||||
$line = fgets($stream);
|
||||
|
||||
if (false === $line) {
|
||||
throw new Pop3Exception('The connection dropped, or the server stopped answering.');
|
||||
}
|
||||
|
||||
return rtrim($line, "\r\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Pop3Exception
|
||||
*/
|
||||
private function expect(string $prefix): void
|
||||
{
|
||||
$reply = $this->readLine();
|
||||
|
||||
if (false === str_starts_with($reply, $prefix)) {
|
||||
throw new Pop3Exception(sprintf('The server did not greet us with %s: %s', $prefix, $reply));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shut the socket down so a read in progress fails at once.
|
||||
*
|
||||
* STREAM_SHUT_RDWR rather than fclose(), for the reason ImapDeadline
|
||||
* gives on the IMAP side: the session's own `finally` will still close it,
|
||||
* and with the resource closed outright that close has to survive a
|
||||
* stream it can no longer inspect.
|
||||
*/
|
||||
public function closeForDeadline(): void
|
||||
{
|
||||
$stream = $this->stream;
|
||||
|
||||
if (null !== $stream && true === is_resource($stream)) {
|
||||
if (false === @stream_socket_shutdown($stream, STREAM_SHUT_RDWR)) {
|
||||
@fclose($stream);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function isOpen(): bool
|
||||
{
|
||||
return null !== $this->stream && true === is_resource($this->stream);
|
||||
}
|
||||
|
||||
private function closeQuietly(): void
|
||||
{
|
||||
$stream = $this->stream;
|
||||
$this->stream = null;
|
||||
|
||||
if (null !== $stream && true === is_resource($stream)) {
|
||||
@fclose($stream);
|
||||
}
|
||||
}
|
||||
|
||||
public function __destruct()
|
||||
{
|
||||
// A session that was never quit() still gets its socket closed. The
|
||||
// server rolls the transaction back on its own, which is the safe
|
||||
// outcome for every path that reaches here.
|
||||
$this->closeQuietly();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Pop3;
|
||||
|
||||
/**
|
||||
* A POP3 conversation that did not go the way it was asked to.
|
||||
*
|
||||
* One exception for the protocol, deliberately, and not a hierarchy: nothing
|
||||
* above this class branches on WHY a session failed. A wrong password, a
|
||||
* certificate the CA store will not accept, a server that hung up mid-RETR and
|
||||
* a DNS failure all cause the same thing — the sync stops, the mailbox's
|
||||
* high-water mark stays where it was, and Account::recordSyncFailure() puts
|
||||
* the message in front of the user. The message is for a person to read; the
|
||||
* class is what tells every caller that the mail is not in hand.
|
||||
*
|
||||
* The one distinction that does get made is in the message text, because
|
||||
* "authentication failed" and "could not reach" are the two a user has
|
||||
* different remedies for. That is a sentence, not a type.
|
||||
*/
|
||||
final class Pop3Exception extends \RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Pop3;
|
||||
|
||||
/**
|
||||
* One line of a POP3 LIST: the message's number in this session, and the
|
||||
* number of octets the server says RETR will return.
|
||||
*
|
||||
* Both only mean anything for the session they came from. The number restarts
|
||||
* at 1 after any deletion and the size is the size on the wire, before
|
||||
* transfer decoding — see Pop3Connection::retrieve() and the note there about
|
||||
* what "octets" counts.
|
||||
*
|
||||
* Kept as an object rather than a tuple because the pair travels together
|
||||
* through the syncer and a positional array of two ints is the kind of thing
|
||||
* that gets swapped at a call site without anything complaining.
|
||||
*/
|
||||
final readonly class Pop3Listing
|
||||
{
|
||||
public function __construct(
|
||||
public int $number,
|
||||
public int $octets,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -274,6 +274,15 @@ final readonly class ConfigBackupUsers
|
||||
// anything the other host did: restored, the target files the mail it
|
||||
// imports the way the user asked for.
|
||||
Account::SETTING_GMAILIFY,
|
||||
// Carried, unlike the ledger below: whether an account deletes from
|
||||
// the server after fetching is a standing instruction its owner gave
|
||||
// on purpose, and a restore that silently turned it back to Keep
|
||||
// would leave a mailbox growing on a server that was being pruned.
|
||||
// Getting it wrong the other way — carrying Delete to a host that
|
||||
// has never fetched — is harmless: nothing is marked until a message
|
||||
// has been stored, and a host with an empty ledger stores before it
|
||||
// deletes.
|
||||
Account::SETTING_POP3_DELETE_MODE,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -312,6 +321,15 @@ final readonly class ConfigBackupUsers
|
||||
Account::SETTING_GRAPH_IMPORT => 'the folders the OTHER host had still to read, by ids and page links that mean nothing to a mailbox connected afresh; the target plans its own on its first sync',
|
||||
Account::SETTING_GRAPH_REMOVALS => 'message ids the other host was waiting to judge, for rows this host does not have',
|
||||
Account::SETTING_IMPORT_TOTAL => 'the size of the mailbox as the other host found it, for a progress line about an import that is not this one',
|
||||
// The one where carrying it would do real damage rather than untidy
|
||||
// bookkeeping: this is the list of messages the OTHER host has
|
||||
// already stored. Restored onto an empty installation it would tell
|
||||
// the sync every message in the mailbox is already here, so nothing
|
||||
// would ever be fetched and the mailbox would come up empty and stay
|
||||
// empty. The target starts its own ledger from its first sync, which
|
||||
// fetches everything — the correct answer for a host that has none of
|
||||
// the mail.
|
||||
Account::SETTING_POP3_SEEN_UIDLS => 'the UIDLs the OTHER host has already stored; carried, a restored installation would believe it holds mail it has never fetched and the mailbox would stay empty',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
|
||||
@@ -33,15 +33,11 @@ final readonly class ImapAccountSyncer implements AccountSyncerInterface
|
||||
|
||||
public function supports(Account $account): bool
|
||||
{
|
||||
if (true === $account->isGmail()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (true === $account->isMicrosoft()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
// isPlainImap() rather than two negatives, since POP3 was added: a
|
||||
// POP3 account is not Gmail and not Microsoft either, and before this
|
||||
// it would have fallen through to here, opened an IMAP connection to a
|
||||
// POP3 port, and failed on the greeting. See Pop3AccountSyncer.
|
||||
return true === $account->isPlainImap();
|
||||
}
|
||||
public function sync(Account $account): array
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Imap;
|
||||
|
||||
use App\Domain\Interface\DeadlineClosable;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Contracts\Service\ResetInterface;
|
||||
use Webklex\PHPIMAP\Client;
|
||||
@@ -95,6 +96,27 @@ final class ImapDeadline implements ResetInterface
|
||||
$this->clients[] = WeakReference::create($client);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same for a POP3 session, whose connection is a bare stream.
|
||||
*
|
||||
* A resource cannot be weakly referenced, so the reference is taken to the
|
||||
* session object that owns it — see Pop3Connection, which implements
|
||||
* DeadlineClosable. Held weakly for the same reason the clients are: this
|
||||
* class must not be what keeps a connection open.
|
||||
*/
|
||||
public function watchSession(DeadlineClosable $session): void
|
||||
{
|
||||
$this->sessions = array_values(array_filter(
|
||||
$this->sessions,
|
||||
static fn (WeakReference $reference): bool => null !== $reference->get(),
|
||||
));
|
||||
|
||||
$this->sessions[] = WeakReference::create($session);
|
||||
}
|
||||
|
||||
/** @var list<WeakReference<DeadlineClosable>> */
|
||||
private array $sessions = [];
|
||||
|
||||
public static function available(): bool
|
||||
{
|
||||
return 'cli' === PHP_SAPI && true === function_exists('pcntl_alarm') && true === function_exists('pcntl_async_signals');
|
||||
@@ -140,6 +162,7 @@ final class ImapDeadline implements ResetInterface
|
||||
|
||||
pcntl_alarm(0);
|
||||
$this->clients = [];
|
||||
$this->sessions = [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -189,6 +212,26 @@ final class ImapDeadline implements ResetInterface
|
||||
|
||||
$this->clients = [];
|
||||
|
||||
// POP3 sessions, shut down the same way and counted in the same total:
|
||||
// the warning below is about a handler that took too long, and which
|
||||
// protocol it was talking is not what the reader of that log line needs.
|
||||
foreach ($this->sessions as $reference) {
|
||||
$session = $reference->get();
|
||||
|
||||
if (null === $session || false === $session->isOpen()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$session->closeForDeadline();
|
||||
++$closed;
|
||||
} catch (\Throwable) {
|
||||
// Already gone: nothing was being read from it.
|
||||
}
|
||||
}
|
||||
|
||||
$this->sessions = [];
|
||||
|
||||
if ($closed > 0) {
|
||||
$this->logger->warning('An IMAP conversation outlasted its deadline and was closed', [
|
||||
'seconds' => $this->seconds,
|
||||
@@ -201,6 +244,7 @@ final class ImapDeadline implements ResetInterface
|
||||
|
||||
public function reset(): void
|
||||
{
|
||||
$this->clients = [];
|
||||
$this->clients = [];
|
||||
$this->sessions = [];
|
||||
}
|
||||
}
|
||||
@@ -148,7 +148,10 @@ readonly class MailboxSyncer
|
||||
'missing' => 0,
|
||||
];
|
||||
|
||||
if (true === $account->isGmail() || true === $account->isMicrosoft()) {
|
||||
// A POP3 server has no folder tree to list, and LIST against one is
|
||||
// not a command it knows. The one Mailbox row a POP3 account gets is
|
||||
// created by its own syncer; nothing here ever touches it.
|
||||
if (false === $account->isPlainImap()) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
|
||||
@@ -541,7 +541,7 @@ class MessageSyncer
|
||||
// the stored .eml all come from the same bytes.
|
||||
$this->dropRepeatedHeader($imapMessage, $uid, $mailbox->fullPath);
|
||||
|
||||
$message = $this->buildMessage($imapMessage, $mailbox, $accountId);
|
||||
$message = $this->buildMessageFromParsed($imapMessage, $mailbox, $accountId);
|
||||
$this->em->persist($message);
|
||||
|
||||
// Only now, with the message whole. buildMessage() used to
|
||||
@@ -954,7 +954,24 @@ class MessageSyncer
|
||||
);
|
||||
}
|
||||
|
||||
private function buildMessage(ImapMessage $imapMessage, Mailbox $mailbox, int $accountId): Message
|
||||
/**
|
||||
* Turn one parsed message into a Message row, attached to a mailbox.
|
||||
*
|
||||
* The single place a row is built from an RFC 822 message, whichever
|
||||
* protocol delivered it. IMAP hands it one the library fetched; POP3 hands
|
||||
* it one the library built with Message::fromString() from a RETR answer.
|
||||
* Nothing below this line knows which — the parser is the shared part, and
|
||||
* that is the reason the two protocols produce identical rows for
|
||||
* identical bytes.
|
||||
*
|
||||
* Does NOT set imapUid and does NOT persist. An IMAP caller sets the UID
|
||||
* after calling this (see storeMessages()); a POP3 caller leaves it null,
|
||||
* because a POP3 message has no UID to give it — see Pop3AccountSyncer.
|
||||
*
|
||||
* Public since POP3 was added. It was private while IMAP was the only
|
||||
* caller and the file was the interface.
|
||||
*/
|
||||
public function buildMessageFromParsed(ImapMessage $imapMessage, Mailbox $mailbox, int $accountId): Message
|
||||
{
|
||||
$message = new Message();
|
||||
$message->account = $mailbox->account;
|
||||
|
||||
@@ -166,7 +166,10 @@ readonly class RemoteLabelRemover
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (null === $specialUse || null === $account->imapHost || true === $account->isGmail() || true === $account->isMicrosoft()) {
|
||||
// A POP3 account is included in this guard for the same reason Gmail
|
||||
// is: it has no folders to provision, and the caller's own fallback —
|
||||
// a label-only arrival point — is the right answer for it.
|
||||
if (null === $specialUse || null === $account->imapHost || false === $account->isPlainImap()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ use App\Domain\DTO\ConnectionTestResult;
|
||||
use App\Domain\Helper\ImapConnectionFactory;
|
||||
use App\Domain\Helper\MailServerHost;
|
||||
use App\Entity\Mail\Account;
|
||||
use App\Infrastructure\Pop3\Pop3Connection;
|
||||
use App\Service\Imap\ImapDeadline;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Mailer\Transport;
|
||||
use Symfony\Component\Mailer\Transport\Smtp\SmtpTransport;
|
||||
@@ -30,12 +32,15 @@ final class ConnectionTester
|
||||
private readonly SmtpDsnFactory $dsnFactory,
|
||||
private readonly TranslatorInterface $translator,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly ImapDeadline $deadline,
|
||||
) {
|
||||
}
|
||||
|
||||
public function test(Account $account): ConnectionTestResult
|
||||
{
|
||||
[$imapOk, $imapMessage] = $this->probeImap($account);
|
||||
[$imapOk, $imapMessage] = true === $account->isPop3()
|
||||
? $this->probePop3($account)
|
||||
: $this->probeImap($account);
|
||||
[$smtpOk, $smtpMessage] = $this->probeSmtp($account);
|
||||
|
||||
return new ConnectionTestResult(
|
||||
@@ -86,6 +91,47 @@ final class ConnectionTester
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The POP3 probe.
|
||||
*
|
||||
* Deliberately answers the same two things the IMAP one does — can we log
|
||||
* in, and how much is there — with the count coming from LIST rather than
|
||||
* from a folder list, because that is the only structure a POP3 server
|
||||
* has. The greeting, the TLS handshake and USER/PASS are all exercised by
|
||||
* open(), so a passing answer means exactly what it means on the IMAP
|
||||
* side: this account will sync.
|
||||
*
|
||||
* @return array{0: bool, 1: string}
|
||||
*/
|
||||
private function probePop3(Account $account): array
|
||||
{
|
||||
try {
|
||||
$connection = Pop3Connection::open(
|
||||
(string) $account->imapHost,
|
||||
(int) $account->imapPort,
|
||||
$account->imapEncryption,
|
||||
(string) $account->username,
|
||||
(string) $account->password,
|
||||
$this->deadline,
|
||||
);
|
||||
|
||||
try {
|
||||
$count = count($connection->list());
|
||||
} finally {
|
||||
$connection->quit();
|
||||
}
|
||||
|
||||
return [true, $this->translator->trans('account.test.result.pop3_ok', ['%count%' => $count])];
|
||||
} catch (Throwable $e) {
|
||||
// 'imap' as the protocol argument on purpose: the categories and
|
||||
// the port hints are keyed by which credential-bearing protocol
|
||||
// failed, and POP3's failures are the same set — bad certificate,
|
||||
// refused login, wrong port — read off the same field pair. A
|
||||
// second key would be two copies of one message.
|
||||
return [false, $this->describe($e, $account, 'imap', $account->imapEncryption, $account->imapPort)];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: bool|null, 1: string}
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,677 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Pop3;
|
||||
|
||||
use App\Domain\DTO\Mail\IngestedMessage;
|
||||
use App\Domain\Enum\Mail\LabelRole;
|
||||
use App\Domain\Enum\Mail\MailboxSpecialUse;
|
||||
use App\Domain\Helper\ImapConnectionFactory;
|
||||
use App\Domain\Interface\AccountSyncerInterface;
|
||||
use App\Entity\Mail\Account;
|
||||
use App\Entity\Mail\Mailbox;
|
||||
use App\Entity\Mail\Message;
|
||||
use App\Entity\Mail\MessageThread;
|
||||
use App\Infrastructure\Pop3\Pop3Connection;
|
||||
use App\Repository\Mail\AccountRepository;
|
||||
use App\Repository\Mail\MailboxRepository;
|
||||
use App\Service\Imap\ImapDeadline;
|
||||
use App\Service\Imap\MessageSyncer;
|
||||
use App\Service\Label\LabelResolver;
|
||||
use App\Service\Label\ThreadLabelSynchronizer;
|
||||
use App\Service\Mail\PostIngestPipeline;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Throwable;
|
||||
use Webklex\PHPIMAP\Message as ImapMessage;
|
||||
|
||||
/**
|
||||
* Bringing a POP3 mailbox into plMail.
|
||||
*
|
||||
* ── The shape of the problem ─────────────────────────────────────────────────
|
||||
* POP3 is not a worse IMAP; it is a different model, and most of this class is
|
||||
* the difference written down. A POP3 server has ONE mailbox, numbered 1..N in
|
||||
* the order it happens to hold them; the numbers are reissued after any
|
||||
* deletion; and the only thing the client may do to a message is read it or
|
||||
* delete it. There are no folders, no flags, no UIDs and nothing to push back.
|
||||
*
|
||||
* What survives from the IMAP path is everything after the bytes are in hand:
|
||||
* webklex parses the message, MessageSyncer builds the row, PostIngestPipeline
|
||||
* does the rest. That reuse is the reason this is a syncer and not a second
|
||||
* mail stack.
|
||||
*
|
||||
* ── How it decides what is new ───────────────────────────────────────────────
|
||||
* The server lists every message in its mailbox on every sync — there is no
|
||||
* "since" and nothing to ask — so the whole of the sync's memory is a list of
|
||||
* UIDLs it has already stored, on the account (Account::$pop3SeenUidls). A
|
||||
* message is new exactly when its UIDL is not in that list.
|
||||
*
|
||||
* UIDL and not the message number, and not the Message-ID. The number is
|
||||
* reissued after deletions, so it names a different message next time. The
|
||||
* Message-ID is absent from real mail more often than one would like, and the
|
||||
* same Message-ID legitimately appears twice (a copy to two of the user's
|
||||
* addresses). UIDL is the one identifier RFC 1939 guarantees is unique and
|
||||
* stable for as long as the message exists — which is exactly the lifetime
|
||||
* that matters for "have I already got this one".
|
||||
*
|
||||
* ── Where the mail lands ─────────────────────────────────────────────────────
|
||||
* One Mailbox row, called after the account, with the Inbox special use. It
|
||||
* is not INBOX by name because it is not an IMAP INBOX — there is nothing to
|
||||
* select — and the sidebar and the label chain both key off the special use
|
||||
* rather than the name. Sent and Drafts are label-only: a POP3 server has
|
||||
* nowhere to put them, so mail plMail sends is labelled Sent and stays here.
|
||||
* See MessageSendService, which already appends to a Sent folder only when
|
||||
* one exists.
|
||||
*
|
||||
* ── What cannot work, and is therefore not attempted ─────────────────────────
|
||||
* - Flags: marking read, starring and moving are LOCAL. Nothing is sent to
|
||||
* the server, because POP3 has no way to receive it. The next sync does not
|
||||
* undo them either, which is the part that makes this a real feature rather
|
||||
* than a display of one: the sync only ever inserts messages it has never
|
||||
* seen, and never revisits a row it has.
|
||||
* - Pushing changes back: ApplyImapFlagsHandler is not reachable for these
|
||||
* accounts, because LabelChangePropagator only queues it for messages that
|
||||
* have an imapUid, and these rows have none. See the note there.
|
||||
* - Deletion in the UI: deleting a message deletes the local row only. The
|
||||
* server's copy is untouched unless the account's delete mode says so at
|
||||
* the next sync, which is a different and emptier promise.
|
||||
*/
|
||||
final readonly class Pop3AccountSyncer implements AccountSyncerInterface
|
||||
{
|
||||
/**
|
||||
* Messages fetched per connection before the session is recycled.
|
||||
*
|
||||
* POP3 holds the mailbox locked for the whole transaction, and providers
|
||||
* cap both the number of messages per session and how long it may stay
|
||||
* open. A hundred is what the largest of these mailboxes comfortably
|
||||
* affords; the number is small on purpose because the cost of recycling is
|
||||
* one re-LIST, and the cost of not recycling is a server that hangs up
|
||||
* half way through a first import.
|
||||
*/
|
||||
private const int BATCH_SIZE = 100;
|
||||
|
||||
/**
|
||||
* How many messages a single sync will fetch.
|
||||
*
|
||||
* The first sync of a mailbox the user has been reading for years is
|
||||
* thousands of messages, and a sync that runs for an hour holds the
|
||||
* mailbox locked against the user's phone for that hour. The remainder is
|
||||
* picked up by the next sync — the account is a normal polled account and
|
||||
* the message it did not reach is still on the server — so the limit costs
|
||||
* nothing but wall time and is invisible to the user except as a progress
|
||||
* line that moves.
|
||||
*/
|
||||
private const int MAX_MESSAGES_PER_SYNC = 500;
|
||||
|
||||
public function __construct(
|
||||
private readonly MessageSyncer $messageSyncer,
|
||||
private readonly AccountRepository $accountRepository,
|
||||
private readonly MailboxRepository $mailboxRepository,
|
||||
private readonly LabelResolver $labelResolver,
|
||||
private readonly ThreadLabelSynchronizer $threadLabels,
|
||||
private readonly PostIngestPipeline $postIngest,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly ManagerRegistry $registry,
|
||||
private readonly ImapDeadline $deadline,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* POP3 only. Gmail, Microsoft and plain IMAP all answer false and are
|
||||
* served by their own syncers.
|
||||
*/
|
||||
public function supports(Account $account): bool
|
||||
{
|
||||
return true === $account->isPop3();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int> the mailboxes written to, for the caller's per-mailbox
|
||||
* follow-up work
|
||||
*/
|
||||
public function sync(Account $account): array
|
||||
{
|
||||
$mailbox = $this->mailboxFor($account);
|
||||
|
||||
$connection = $this->openSession($account);
|
||||
$stored = 0;
|
||||
|
||||
try {
|
||||
$listing = $connection->list();
|
||||
$uidls = $connection->uidl();
|
||||
|
||||
if ([] === $listing) {
|
||||
$this->logger->info('POP3 sync: the mailbox is empty', ['accountId' => $account->id]);
|
||||
|
||||
$connection->quit();
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
// A message the server lists but does not name is not one this
|
||||
// sync can recognise, so it is skipped rather than guessed at.
|
||||
// That is a broken server, and the alternative — fetching it under
|
||||
// a made-up identifier — stores it again on every sync for ever.
|
||||
$unnamed = count($listing) - count($uidls);
|
||||
|
||||
if ($unnamed > 0) {
|
||||
$this->logger->warning('POP3 sync: the server listed messages without UIDLs; they cannot be recognised', [
|
||||
'accountId' => $account->id,
|
||||
'count' => $unnamed,
|
||||
]);
|
||||
}
|
||||
|
||||
$new = [];
|
||||
$seen = $account->pop3SeenUidls;
|
||||
|
||||
foreach ($listing as $entry) {
|
||||
$uidl = $uidls[$entry->number] ?? null;
|
||||
|
||||
if (null === $uidl || true === in_array($uidl, $seen, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$new[] = ['number' => $entry->number, 'uidl' => $uidl];
|
||||
}
|
||||
|
||||
if ([] === $new) {
|
||||
$this->logger->debug('POP3 sync: nothing new', ['accountId' => $account->id, 'listed' => count($listing)]);
|
||||
|
||||
// The delete mode still has work to do on an account that has
|
||||
// caught up: a message stored on an earlier sync may be one
|
||||
// the user has only now asked to be removed from the server.
|
||||
$this->deleteStored($account, $connection, $uidls);
|
||||
|
||||
$connection->quit();
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
$pending = count($new);
|
||||
|
||||
if ($pending > self::MAX_MESSAGES_PER_SYNC) {
|
||||
$new = array_slice($new, 0, self::MAX_MESSAGES_PER_SYNC);
|
||||
|
||||
$this->logger->info('POP3 sync: message limit reached; the rest is left for the next sync', [
|
||||
'accountId' => $account->id,
|
||||
'limit' => self::MAX_MESSAGES_PER_SYNC,
|
||||
'remaining' => $pending - count($new),
|
||||
]);
|
||||
}
|
||||
|
||||
foreach (array_chunk($new, self::BATCH_SIZE) as $index => $batch) {
|
||||
// A fresh session per batch. quit() ends the one before it,
|
||||
// which is also the moment that batch's delete marks become
|
||||
// real on the server — see Pop3Connection on the transaction
|
||||
// state, and why the marks travel with the session and not
|
||||
// with the row.
|
||||
if (0 !== $index) {
|
||||
$connection->quit();
|
||||
|
||||
$account = $this->refindAccount($account);
|
||||
$mailbox = $this->mailboxFor($account);
|
||||
$connection = $this->openSession($account);
|
||||
}
|
||||
|
||||
$stored += $this->fetchBatch($account, $mailbox, $connection, $batch);
|
||||
}
|
||||
|
||||
$account = $this->refindAccount($account);
|
||||
$account->recordSyncSuccess();
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
$connection->quit();
|
||||
} catch (Throwable $failure) {
|
||||
// Nothing that was marked for deletion is deleted: abort() does
|
||||
// not send QUIT, and a POP3 server rolls the transaction back when
|
||||
// the session ends any other way. The UIDLs of whatever did store
|
||||
// are already committed — rememberStored() flushes — so the next
|
||||
// sync does not fetch them again.
|
||||
$connection->abort();
|
||||
|
||||
throw $failure;
|
||||
}
|
||||
|
||||
return 0 === $stored ? [] : [(int) $mailbox->id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and store one batch, then mark its messages for deletion if this
|
||||
* account wants them gone.
|
||||
*
|
||||
* @param list<array{number: int, uidl: string}> $batch
|
||||
*
|
||||
* @return int how many were stored
|
||||
*/
|
||||
private function fetchBatch(Account $account, Mailbox $mailbox, Pop3Connection $connection, array $batch): int
|
||||
{
|
||||
$stored = [];
|
||||
$rawSour = [];
|
||||
|
||||
foreach ($batch as $entry) {
|
||||
try {
|
||||
$raw = $connection->retrieve($entry['number']);
|
||||
} catch (Throwable $failure) {
|
||||
// One message the server will not hand over is one message.
|
||||
// The rest of the batch is still worth fetching, and the UIDL
|
||||
// is not recorded, so the next sync asks for it again.
|
||||
$this->logger->warning('POP3 sync: the server would not send a message; it is asked for again next sync', [
|
||||
'accountId' => $account->id,
|
||||
'uidl' => $entry['uidl'],
|
||||
'error' => $failure->getMessage(),
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$message = $this->buildMessage($raw, $mailbox, (int) $account->id);
|
||||
|
||||
// No UID: there is none, and inventing one would point the row
|
||||
// at a number the server reassigns. The unique constraint on
|
||||
// (mailbox_id, imap_uid) permits any number of NULLs, which is
|
||||
// exactly what is wanted here.
|
||||
$message->imapUid = null;
|
||||
|
||||
$this->em->persist($message);
|
||||
|
||||
foreach ($message->messageParts as $part) {
|
||||
$this->em->persist($part);
|
||||
}
|
||||
|
||||
$stored[] = $message;
|
||||
$rawSour[] = $raw;
|
||||
} catch (Throwable $failure) {
|
||||
// A refused write closes the manager; reopen it so the rest of
|
||||
// the batch is not blamed for it. Same contract as the IMAP
|
||||
// syncer's one-at-a-time fallback. The registry is used rather
|
||||
// than the manager itself because resetManager() lives on the
|
||||
// one, and a closed manager cannot reopen itself.
|
||||
if (false === $this->em->isOpen()) {
|
||||
$this->registry->resetManager();
|
||||
|
||||
$mailbox = $this->mailboxRepository->find($mailbox->id);
|
||||
|
||||
if (null === $mailbox) {
|
||||
// The row is gone — an account deleted during a long
|
||||
// first import. There is nowhere left to file anything.
|
||||
throw $failure;
|
||||
}
|
||||
}
|
||||
|
||||
$this->logger->error('POP3 sync: a message could not be stored; it is asked for again next sync', [
|
||||
'accountId' => $account->id,
|
||||
'uidl' => $entry['uidl'],
|
||||
'error' => $failure->getMessage(),
|
||||
'exception' => $failure,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// The rows exist, and this is the commit that makes them permanent.
|
||||
// The delete marks below are only ever made AFTER it returns: a
|
||||
// message told to the server as "got it" must be a message that is
|
||||
// actually here.
|
||||
$this->em->flush();
|
||||
|
||||
$ingested = [];
|
||||
|
||||
foreach ($stored as $index => $message) {
|
||||
$ingested[] = new IngestedMessage($message, $account, $rawSour[$index] ?? '');
|
||||
}
|
||||
|
||||
if ([] !== $ingested) {
|
||||
$this->postIngest->run($account, $ingested);
|
||||
|
||||
// The pipeline threads the messages, and MessageThreader copies a
|
||||
// message's labels onto its thread only when the message is FIRST
|
||||
// attached. That is enough for IMAP, whose mailbox label always
|
||||
// exists by then. Here the label is attached by
|
||||
// buildMessageFromParsed() before the pipeline runs, so it is
|
||||
// already there — but a message that joins a conversation which
|
||||
// already had messages contributes its label to a thread the
|
||||
// threader does not revisit, so the union is re-stated.
|
||||
//
|
||||
// Cheap and idempotent: sync() sets the union both ways, and the
|
||||
// threads are already loaded by the pipeline's threading pass.
|
||||
foreach ($ingested as $item) {
|
||||
if (null !== $item->message->thread) {
|
||||
$this->threadLabels->sync($item->message->thread);
|
||||
}
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
$this->rememberStored($account, array_column($batch, 'uidl'));
|
||||
|
||||
if (true === $account->pop3DeleteMode->deletes()) {
|
||||
foreach ($batch as $entry) {
|
||||
if (false === in_array($entry['uidl'], $account->pop3SeenUidls, true)) {
|
||||
// It was never stored — the server's copy is the only one.
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$connection->markDeleted($entry['number'], $entry['uidl']);
|
||||
} catch (Throwable $failure) {
|
||||
$this->logger->warning('POP3 sync: a message could not be marked for deletion', [
|
||||
'accountId' => $account->id,
|
||||
'uidl' => $entry['uidl'],
|
||||
'error' => $failure->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return count($stored);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete from the server what is already here, for an account that has
|
||||
* asked for it.
|
||||
*
|
||||
* The path taken when nothing new arrived. Delete mode is a standing
|
||||
* instruction, not a one-off at fetch time: an account switched to Delete
|
||||
* should not have to wait for new mail before the old mail goes.
|
||||
*
|
||||
* @param array<int, string> $uidls message number => UIDL
|
||||
*/
|
||||
private function deleteStored(Account $account, Pop3Connection $connection, array $uidls): void
|
||||
{
|
||||
if (false === $account->pop3DeleteMode->deletes()) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($uidls as $number => $uidl) {
|
||||
if (false === $account->hasStoredPop3Uidl($uidl)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$connection->markDeleted($number, $uidl);
|
||||
} catch (Throwable $failure) {
|
||||
$this->logger->warning('POP3 sync: a message could not be marked for deletion', [
|
||||
'accountId' => $account->id,
|
||||
'uidl' => $uidl,
|
||||
'error' => $failure->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The one mailbox a POP3 account's mail lives in, created on first use and
|
||||
* bound to the account's Inbox label.
|
||||
*
|
||||
* ── The binding is not optional ──────────────────────────────────────────
|
||||
* Every view in the application is label-driven, not mailbox-driven: the
|
||||
* Inbox list, the sidebar counts and the JMAP Mailbox object all read the
|
||||
* label binding that MailboxSyncer would normally create. A Mailbox row
|
||||
* with no binding is a row nothing displays, and MessageSyncer's
|
||||
* buildMessageFromParsed() attaches `$mailbox->label` — null here — so the
|
||||
* messages landed with no label at all and were invisible in the UI while
|
||||
* being present in the database.
|
||||
*
|
||||
* Found by deploying it: five messages synced, none of them appeared. IMAP
|
||||
* never hits this because MailboxSyncer::linkLabel() binds every folder it
|
||||
* creates, and its `$mailbox->label` is therefore never null.
|
||||
*/
|
||||
private function mailboxFor(Account $account): Mailbox
|
||||
{
|
||||
$existing = $this->mailboxRepository->findOneBy([
|
||||
'account' => $account,
|
||||
'specialUse' => MailboxSpecialUse::INBOX,
|
||||
]);
|
||||
|
||||
if (null !== $existing) {
|
||||
// A row created before the binding existed — including one this
|
||||
// syncer made on an earlier run — is healed here rather than left
|
||||
// as mail the user cannot see.
|
||||
if (null === $existing->label) {
|
||||
$this->labelResolver->bindMailbox(
|
||||
$this->labelResolver->systemLabel(LabelRole::Inbox, $account),
|
||||
$existing,
|
||||
);
|
||||
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
// The import tracker reads a mailbox as "still importing" while
|
||||
// $importFloorUid is null — "nobody has looked yet" — and this
|
||||
// syncer never sets it, because that marker is the IMAP importer's
|
||||
// page-walk bookkeeping and a POP3 mailbox has no page walk: one
|
||||
// pass brings in everything the server has. Left null, every POP3
|
||||
// account read as importing for ever and the topbar badge spun
|
||||
// for ever with it. Zero is the IMAP importer's own "done" value
|
||||
// (ImapFolderProvisioner sets it the same way), so this is the
|
||||
// same state that machinery arrives at, written by the one path
|
||||
// that has no reason ever to arrive at it.
|
||||
if (null === $existing->importFloorUid) {
|
||||
$existing->importFloorUid = 0;
|
||||
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
// The mailbox's own binding is only half of it. Messages already
|
||||
// stored carry the labels THEY were given at build time, and the
|
||||
// ones written before this fix were given none — binding the
|
||||
// mailbox now does not retroactively label them, and the sync will
|
||||
// never see them again because their UIDLs are in the ledger. So
|
||||
// anything sitting in this mailbox with no labels at all gets the
|
||||
// Inbox label now.
|
||||
//
|
||||
// Deliberately "no labels at all" rather than "not this label":
|
||||
// a message the user has since filed somewhere else carries its own
|
||||
// labels and must be left alone, and re-adding Inbox to it would
|
||||
// pull it back into a list they had taken it out of.
|
||||
$this->labelOrphanedMessages($existing);
|
||||
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$mailbox = new Mailbox();
|
||||
$mailbox->account = $account;
|
||||
$mailbox->name = 'Inbox';
|
||||
$mailbox->fullPath = 'INBOX';
|
||||
$mailbox->specialUse = MailboxSpecialUse::INBOX;
|
||||
$mailbox->isSelectable = true;
|
||||
$mailbox->isSyncEnabled = true;
|
||||
// No import walk to do — see the healing branch above for why the
|
||||
// tracker needs this rather than null.
|
||||
$mailbox->importFloorUid = 0;
|
||||
|
||||
// isSyncEnabled on a POP3 account is a switch with nothing to switch:
|
||||
// there is one mailbox and no folder tree, so there is no second one to
|
||||
// turn on or off. It is true so that the rest of the application's
|
||||
// "is this folder polled" checks read a POP3 account's mail as polled,
|
||||
// which it is.
|
||||
//
|
||||
// Persisted before the binding, for the same reason MailboxSyncer does:
|
||||
// bindMailbox() flushes to mint the binding id, and the FK needs the
|
||||
// mailbox to be managed by then.
|
||||
$this->em->persist($mailbox);
|
||||
|
||||
$this->labelResolver->bindMailbox(
|
||||
$this->labelResolver->systemLabel(LabelRole::Inbox, $account),
|
||||
$mailbox,
|
||||
);
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return $mailbox;
|
||||
}
|
||||
|
||||
/**
|
||||
* Give the Inbox label to messages in this mailbox that carry no label at
|
||||
* all.
|
||||
*
|
||||
* The repair half of the label fix, and deliberately narrow: "no labels"
|
||||
* means no view can show the message, which is unambiguously wrong. A
|
||||
* message with ANY label is one somebody or something has filed on purpose,
|
||||
* and adding Inbox to it would pull it back into a list it was taken out of
|
||||
* — which is what archiving is. So this touches only the invisible ones.
|
||||
*
|
||||
* One query and one flush, and only when there is something to repair: a
|
||||
* healthy account finds nothing and pays an indexed count.
|
||||
*/
|
||||
private function labelOrphanedMessages(Mailbox $mailbox): void
|
||||
{
|
||||
$label = $mailbox->label;
|
||||
|
||||
if (null === $label) {
|
||||
return;
|
||||
}
|
||||
|
||||
$accountId = (int) $mailbox->account->id;
|
||||
$mailboxId = (int) $mailbox->id;
|
||||
|
||||
/** @var list<Message> $orphans */
|
||||
$orphans = $this->em->createQueryBuilder()
|
||||
->select('m')
|
||||
->from(Message::class, 'm')
|
||||
->where('m.account = :accountId')
|
||||
->andWhere('m.mailbox = :mailboxId')
|
||||
// A DQL subselect cannot name the join table, so this is written as
|
||||
// "has no labels" through the collection, which Doctrine compiles
|
||||
// to the same NOT EXISTS on message_label.
|
||||
->andWhere('m.labels IS EMPTY')
|
||||
->setParameter('accountId', $accountId)
|
||||
->setParameter('mailboxId', $mailboxId)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
if ([] !== $orphans) {
|
||||
foreach ($orphans as $orphan) {
|
||||
$orphan->addLabel($label);
|
||||
}
|
||||
|
||||
$this->logger->info('POP3 sync: gave the Inbox label to messages that had none', [
|
||||
'accountId' => $accountId,
|
||||
'count' => count($orphans),
|
||||
]);
|
||||
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
// The conversations, ALWAYS — and separately from the messages, because
|
||||
// they are two different repairs and an account can need only the
|
||||
// second. The inbox lists threads, and its query joins the THREAD's
|
||||
// labels; a message labelled while its conversation carries none is
|
||||
// still invisible.
|
||||
//
|
||||
// Learned the hard way: the message repair above ran first and did its
|
||||
// job, so on the next run there were no orphan messages left and this
|
||||
// half never ran at all — the mail stayed invisible while every row
|
||||
// looked correct.
|
||||
$this->labelUnlabelledThreads($accountId, $mailboxId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Threads in this mailbox that carry a label their messages have and they
|
||||
* do not.
|
||||
*
|
||||
* Stated as "the union is out of step" rather than "the thread has none",
|
||||
* because the threader attaches labels when a message first joins a
|
||||
* conversation and never revisits one — so a reply landing on an existing
|
||||
* conversation can leave its label on the message and not on the thread.
|
||||
* ThreadLabelSynchronizer is the application's own rule for this and is
|
||||
* used rather than a hand-written copy.
|
||||
*/
|
||||
private function labelUnlabelledThreads(int $accountId, int $mailboxId): void
|
||||
{
|
||||
/** @var list<MessageThread> $threads */
|
||||
$threads = $this->em->createQueryBuilder()
|
||||
->select('DISTINCT t')
|
||||
->from(MessageThread::class, 't')
|
||||
->join('t.messages', 'm')
|
||||
->where('t.account = :accountId')
|
||||
->andWhere('m.mailbox = :mailboxId')
|
||||
->setParameter('accountId', $accountId)
|
||||
->setParameter('mailboxId', $mailboxId)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
$repaired = 0;
|
||||
|
||||
foreach ($threads as $thread) {
|
||||
$before = count($thread->labels);
|
||||
|
||||
$this->threadLabels->sync($thread);
|
||||
|
||||
if (count($thread->labels) !== $before) {
|
||||
++$repaired;
|
||||
}
|
||||
}
|
||||
|
||||
if ($repaired > 0) {
|
||||
$this->logger->info('POP3 sync: brought conversation labels back in step with their messages', [
|
||||
'accountId' => $accountId,
|
||||
'count' => $repaired,
|
||||
]);
|
||||
|
||||
$this->em->flush();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one RETR answer into a Message row, through the same parser an IMAP
|
||||
* fetch goes through.
|
||||
*
|
||||
* The handover is the whole point of the design: webklex's Message is a
|
||||
* MIME parser with a protocol client attached, and fromString() builds one
|
||||
* from bytes with no client at all. Everything downstream — the body
|
||||
* extraction, the attachment parts, the charset repair, the date fallback
|
||||
* — behaves identically to IMAP because it is the same code.
|
||||
*
|
||||
* Reflective only for buildMessage(), which is private and takes an
|
||||
* ImapMessage because that is what it has always taken. Making it public
|
||||
* and typed to the parser would be the cleaner change and is a follow-up:
|
||||
* this commit's job is to add a protocol, not to refactor a class with
|
||||
* twelve call sites.
|
||||
*/
|
||||
private function buildMessage(string $raw, Mailbox $mailbox, int $accountId): Message
|
||||
{
|
||||
$imapMessage = ImapMessage::fromString($raw, ImapConnectionFactory::config());
|
||||
|
||||
return $this->messageSyncer->buildMessageFromParsed($imapMessage, $mailbox, $accountId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record what was stored, and tell the account's sync mark.
|
||||
*/
|
||||
private function rememberStored(Account $account, array $uidls): void
|
||||
{
|
||||
$account->rememberPop3Uidls(array_values(array_filter($uidls, static fn (mixed $uidl): bool => true === is_string($uidl) && '' !== $uidl)));
|
||||
$account->lastSyncedAt = new DateTimeImmutable();
|
||||
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
private function openSession(Account $account): Pop3Connection
|
||||
{
|
||||
return Pop3Connection::open(
|
||||
(string) $account->imapHost,
|
||||
(int) $account->imapPort,
|
||||
$account->imapEncryption,
|
||||
(string) $account->username,
|
||||
(string) $account->password,
|
||||
$this->deadline,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The account again, after a session that may have cleared the manager.
|
||||
*/
|
||||
private function refindAccount(Account $account): Account
|
||||
{
|
||||
$found = $this->accountRepository->find($account->id);
|
||||
|
||||
return $found instanceof Account ? $found : $account;
|
||||
}
|
||||
}
|
||||
@@ -55,9 +55,23 @@ genuinely needed, merge onto form.<field>.vars.attr.
|
||||
|
||||
<div class="border-t border-line"></div>
|
||||
|
||||
{# ── Incoming — IMAP ──────────────────────────────────────────────────── #}
|
||||
{# ── Which protocol the incoming half speaks ───────────────────────────
|
||||
Above the connection fields because it decides what they are called and
|
||||
what port they start from. Two radios and no select: there are two, they
|
||||
are mutually exclusive, and the non-default one is why somebody is here. #}
|
||||
<div class="space-y-2">
|
||||
{{ form_row(form.incomingProtocol, { label: 'account.form.protocol.label'|trans }) }}
|
||||
</div>
|
||||
|
||||
<div class="border-t border-line"></div>
|
||||
|
||||
{# ── Incoming — IMAP or POP3 ────────────────────────────────────────────
|
||||
One block, both protocols: the host, port and encryption fields are the
|
||||
same three either way, and only their labels and defaults differ. The
|
||||
Stimulus controller renames the heading and the host label on change. #}
|
||||
<div class="space-y-3">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-ink-faint">
|
||||
<p data-settings--imap-preset-target="incomingLabel"
|
||||
class="text-[11px] font-semibold uppercase tracking-wider text-ink-faint">
|
||||
{{ 'account.form.imap.heading'|trans }}
|
||||
</p>
|
||||
<div class="grid grid-cols-[1fr_100px] gap-3">
|
||||
@@ -65,6 +79,16 @@ genuinely needed, merge onto form.<field>.vars.attr.
|
||||
{{ form_row(form.imapPort, { label: 'account.form.port'|trans }) }}
|
||||
</div>
|
||||
{{ form_row(form.imapEncryption, { label: 'account.form.encryption'|trans }) }}
|
||||
|
||||
{# POP3 only. Hidden — not removed — when IMAP is chosen, so a radio
|
||||
switched back and forth keeps whatever the user picked. The server
|
||||
reads it either way; AccountType's POST_SUBMIT writes it to the bag. #}
|
||||
<div data-settings--imap-preset-target="pop3Options" class="hidden">
|
||||
{{ form_row(form.pop3DeleteMode, {
|
||||
label: 'account.form.pop3.delete_label'|trans,
|
||||
help: 'account.form.pop3.delete_help'|trans,
|
||||
}) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-line"></div>
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Entity\Mail;
|
||||
|
||||
use App\Domain\Enum\Account\IncomingProtocol;
|
||||
use App\Domain\Enum\Account\Pop3DeleteMode;
|
||||
use App\Entity\Mail\Account;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* The per-account state POP3 added: which protocol, the delete mode, and the
|
||||
* ledger of UIDLs already stored.
|
||||
*
|
||||
* All three live on a plain entity with no database, which is the point — the
|
||||
* rules that matter here (what an unrecognised delete mode means, what happens
|
||||
* when the ledger fills) are the kind that silently lose mail when they are
|
||||
* wrong, and none of them need a server to state.
|
||||
*/
|
||||
final class AccountPop3Test extends TestCase
|
||||
{
|
||||
/**
|
||||
* A new account is IMAP, because the column's default is.
|
||||
*
|
||||
* The one that would be catastrophic the other way round: an account born
|
||||
* POP3 would have an IMAP syncer reach for a folder listing against a POP3
|
||||
* port on its first sync.
|
||||
*/
|
||||
public function testAnAccountStartsAsImap(): void
|
||||
{
|
||||
$account = new Account();
|
||||
|
||||
self::assertSame(IncomingProtocol::Imap, $account->incomingProtocol);
|
||||
self::assertTrue($account->isPlainImap());
|
||||
self::assertFalse($account->isPop3());
|
||||
}
|
||||
|
||||
public function testAPop3AccountIsNotPlainImap(): void
|
||||
{
|
||||
$account = new Account();
|
||||
$account->incomingProtocol = IncomingProtocol::Pop3;
|
||||
|
||||
self::assertTrue($account->isPop3());
|
||||
self::assertFalse($account->isPlainImap());
|
||||
self::assertFalse($account->isGmail());
|
||||
self::assertFalse($account->isMicrosoft());
|
||||
}
|
||||
|
||||
/**
|
||||
* Nothing chosen means nothing deleted.
|
||||
*
|
||||
* The default that keeps mail, and the reason it is the default: a POP3
|
||||
* server's copy is the only second copy plMail has.
|
||||
*/
|
||||
public function testTheDeleteModeDefaultsToKeepingMailOnTheServer(): void
|
||||
{
|
||||
self::assertSame(Pop3DeleteMode::Keep, (new Account())->pop3DeleteMode);
|
||||
self::assertFalse((new Account())->pop3DeleteMode->deletes());
|
||||
}
|
||||
|
||||
/**
|
||||
* Anything the enum does not recognise reads as Keep.
|
||||
*
|
||||
* The value lives in a jsonb bag, so a restored backup, a hand edit or a
|
||||
* value from a future version can all put something else there. Reading
|
||||
* that as the destructive answer is the one failure this setting must not
|
||||
* have, and it is stated here rather than being left to every caller.
|
||||
*
|
||||
*/
|
||||
#[DataProvider('unrecognisedDeleteModes')]
|
||||
public function testAnUnrecognisedDeleteModeMeansKeep(mixed $stored): void
|
||||
{
|
||||
$account = new Account();
|
||||
$account->setSetting(Account::SETTING_POP3_DELETE_MODE, $stored);
|
||||
|
||||
self::assertSame(Pop3DeleteMode::Keep, $account->pop3DeleteMode);
|
||||
self::assertFalse($account->pop3DeleteMode->deletes());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{mixed}>
|
||||
*/
|
||||
public static function unrecognisedDeleteModes(): iterable
|
||||
{
|
||||
yield 'null' => [null];
|
||||
yield 'empty string' => [''];
|
||||
yield 'a typo' => ['delet'];
|
||||
yield 'a future value' => ['delete-after-90-days'];
|
||||
yield 'an array' => [['delete']];
|
||||
yield 'a bool' => [true];
|
||||
}
|
||||
|
||||
public function testTheDeleteModeRoundTrips(): void
|
||||
{
|
||||
$account = new Account();
|
||||
$account->pop3DeleteMode = Pop3DeleteMode::Delete;
|
||||
|
||||
self::assertTrue($account->pop3DeleteMode->deletes());
|
||||
self::assertSame('delete', $account->getSetting(Account::SETTING_POP3_DELETE_MODE));
|
||||
}
|
||||
|
||||
public function testAFreshAccountHasStoredNothing(): void
|
||||
{
|
||||
$account = new Account();
|
||||
|
||||
self::assertSame([], $account->pop3SeenUidls);
|
||||
self::assertFalse($account->hasStoredPop3Uidl('anything'));
|
||||
}
|
||||
|
||||
public function testRecordingAUidlMakesItRecognisable(): void
|
||||
{
|
||||
$account = new Account();
|
||||
$account->rememberPop3Uidls(['aaa', 'bbb']);
|
||||
|
||||
self::assertTrue($account->hasStoredPop3Uidl('aaa'));
|
||||
self::assertTrue($account->hasStoredPop3Uidl('bbb'));
|
||||
self::assertFalse($account->hasStoredPop3Uidl('ccc'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Recording the same UIDL twice does not grow the ledger.
|
||||
*
|
||||
* A sync that retries a message it has already stored — the delete mark
|
||||
* failed, say — must not spend a slot on it and push an older UIDL out.
|
||||
*/
|
||||
public function testRecordingTheSameUidlTwiceDoesNotDuplicateIt(): void
|
||||
{
|
||||
$account = new Account();
|
||||
$account->rememberPop3Uidls(['aaa']);
|
||||
$account->rememberPop3Uidls(['aaa', 'bbb']);
|
||||
|
||||
self::assertSame(['aaa', 'bbb'], $account->pop3SeenUidls);
|
||||
}
|
||||
|
||||
/**
|
||||
* The ledger keeps the newest entries when it fills.
|
||||
*
|
||||
* A POP3 server lists its whole mailbox every sync, so what is worth
|
||||
* remembering is what arrived recently. Dropping from the front is what
|
||||
* makes the cap invisible: the messages that fall out are ones the server
|
||||
* has already stopped offering.
|
||||
*/
|
||||
public function testTheLedgerKeepsTheNewestUidlsWhenItFills(): void
|
||||
{
|
||||
$account = new Account();
|
||||
|
||||
// One over the cap, so exactly one falls off.
|
||||
$uidls = [];
|
||||
for ($i = 0; $i <= Account::POP3_UIDL_MEMORY; ++$i) {
|
||||
$uidls[] = 'uidl-' . $i;
|
||||
}
|
||||
|
||||
$account->pop3SeenUidls = $uidls;
|
||||
|
||||
$stored = $account->pop3SeenUidls;
|
||||
|
||||
self::assertCount(Account::POP3_UIDL_MEMORY, $stored);
|
||||
self::assertSame('uidl-1', $stored[0], 'the oldest went, not the newest');
|
||||
self::assertSame('uidl-' . Account::POP3_UIDL_MEMORY, $stored[count($stored) - 1]);
|
||||
self::assertFalse($account->hasStoredPop3Uidl('uidl-0'));
|
||||
}
|
||||
|
||||
/**
|
||||
* A ledger that is not a list of strings keeps only what is usable.
|
||||
*
|
||||
* The bag is jsonb and a restored or hand-edited value can be anything.
|
||||
* The rule is per entry: strings survive, everything else is dropped, and
|
||||
* a value that is not a list at all reads as empty. Dropping an entry
|
||||
* costs a message fetched again; keeping a junk entry would cost the
|
||||
* message never being fetched.
|
||||
*/
|
||||
public function testAMalformedLedgerKeepsOnlyItsUsableEntries(): void
|
||||
{
|
||||
$cases = [
|
||||
'null' => [null, []],
|
||||
'a bare string' => ['uidl', []],
|
||||
'an int' => [42, []],
|
||||
// A JSON object rather than a list: its string VALUES come back
|
||||
// as the ledger. Harmless — a map was never a ledger anyone wrote
|
||||
// — and the alternative, refusing it wholesale, would drop real
|
||||
// UIDLs if jsonb ever re-encoded a list as an object, which it
|
||||
// does not but which nothing should depend on.
|
||||
'a map, not a list' => [['a' => 'b'], ['b']],
|
||||
'mixed' => [['ok', 7, null, ''], ['ok']],
|
||||
];
|
||||
|
||||
foreach ($cases as $label => [$junk, $expected]) {
|
||||
$account = new Account();
|
||||
$account->setSetting(Account::SETTING_POP3_SEEN_UIDLS, $junk);
|
||||
|
||||
self::assertSame($expected, $account->pop3SeenUidls, $label);
|
||||
}
|
||||
}
|
||||
|
||||
public function testRememberingNothingChangesNothing(): void
|
||||
{
|
||||
$account = new Account();
|
||||
$account->pop3SeenUidls = ['aaa'];
|
||||
$account->rememberPop3Uidls([]);
|
||||
|
||||
self::assertSame(['aaa'], $account->pop3SeenUidls);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Infrastructure\Pop3;
|
||||
|
||||
use App\Infrastructure\Pop3\Pop3Connection;
|
||||
use App\Infrastructure\Pop3\Pop3Exception;
|
||||
use App\Service\Imap\ImapDeadline;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
/**
|
||||
* The POP3 conversation, against a real socket.
|
||||
*
|
||||
* Plaintext and a loopback listener, because everything worth testing here is
|
||||
* the protocol — the greeting, the multi-line framing, dot-unstuffing, the
|
||||
* transaction that only commits on QUIT — and none of it needs TLS to be
|
||||
* exercised. The TLS path shares the same read/write code and is covered by
|
||||
* the fact that a real server is what the feature is for; a handshake test
|
||||
* would be testing PHP's ssl layer.
|
||||
*
|
||||
* The server is scripted rather than implemented: this class asserts on the
|
||||
* bytes the client sends and the bytes it makes of the answer, which is the
|
||||
* contract every one of these tests is about.
|
||||
*/
|
||||
final class Pop3ConnectionTest extends TestCase
|
||||
{
|
||||
/** @var resource|null */
|
||||
private $listener = null;
|
||||
|
||||
/** @var list<resource> */
|
||||
private array $peers = [];
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
foreach ($this->peers as $peer) {
|
||||
if (true === is_resource($peer)) {
|
||||
fclose($peer);
|
||||
}
|
||||
}
|
||||
|
||||
if (null !== $this->listener && true === is_resource($this->listener)) {
|
||||
fclose($this->listener);
|
||||
}
|
||||
|
||||
$this->listener = null;
|
||||
$this->peers = [];
|
||||
$this->child = null;
|
||||
}
|
||||
|
||||
public function testItLogsInAndListsWhatTheServerHolds(): void
|
||||
{
|
||||
$this->serve(static function (array $server): void {
|
||||
$server['expect']('USER someone@example.com');
|
||||
$server['send']("+OK\r\n");
|
||||
|
||||
$server['expect']('PASS secret');
|
||||
$server['send']("+OK 2 messages\r\n");
|
||||
|
||||
$server['expect']('LIST');
|
||||
$server['send']("+OK 2 messages (320 octets)\r\n1 120\r\n2 200\r\n.\r\n");
|
||||
|
||||
$server['expect']('UIDL');
|
||||
$server['send']("+OK\r\n1 aaa111\r\n2 bbb222\r\n.\r\n");
|
||||
|
||||
$server['expect']('QUIT');
|
||||
$server['send']("+OK bye\r\n");
|
||||
});
|
||||
|
||||
$connection = $this->connect();
|
||||
|
||||
$listing = $connection->list();
|
||||
$uidls = $connection->uidl();
|
||||
|
||||
$connection->quit();
|
||||
|
||||
self::assertCount(2, $listing);
|
||||
self::assertSame(1, $listing[0]->number);
|
||||
self::assertSame(120, $listing[0]->octets);
|
||||
self::assertSame(2, $listing[1]->number);
|
||||
|
||||
self::assertSame([1 => 'aaa111', 2 => 'bbb222'], $uidls);
|
||||
self::assertContains('USER someone@example.com', $this->received());
|
||||
self::assertContains('PASS secret', $this->received());
|
||||
}
|
||||
|
||||
/**
|
||||
* A message that begins with a line containing only a period, and lines
|
||||
* that begin with a period, come back exactly as the sender wrote them.
|
||||
*
|
||||
* This is the one transformation RFC 1939 §3 asks the client to undo, and
|
||||
* getting it wrong corrupts the message in a way that only shows up when
|
||||
* the parser reaches the line — which for a multipart body means a
|
||||
* boundary that is no longer a boundary.
|
||||
*/
|
||||
public function testRetrieveUndoesTheDotStuffing(): void
|
||||
{
|
||||
$body = "Subject: Hello\r\n"
|
||||
. "Content-Type: text/plain\r\n"
|
||||
. "\r\n"
|
||||
. "...the end is near.\r\n"
|
||||
. "A normal line.\r\n"
|
||||
. "..another dotted line\r\n";
|
||||
|
||||
$this->serve(static function (array $server) use ($body): void {
|
||||
$server['expect']('USER someone@example.com');
|
||||
$server['send']("+OK\r\n");
|
||||
$server['expect']('PASS secret');
|
||||
$server['send']("+OK\r\n");
|
||||
|
||||
$server['expect']('RETR 1');
|
||||
|
||||
// What the server puts on the wire: a period is added to a line
|
||||
// that already starts with one (and only that), and a lone period
|
||||
// terminates. Adding one to EVERY line, which an earlier version
|
||||
// of this fixture did, tests a server no POP3 describes.
|
||||
$wire = '';
|
||||
foreach (explode("\r\n", rtrim($body, "\r\n")) as $line) {
|
||||
$wire .= (str_starts_with($line, '.') ? '.' . $line : $line) . "\r\n";
|
||||
}
|
||||
$wire .= ".\r\n";
|
||||
|
||||
$server['send']("+OK 120 octets\r\n" . $wire);
|
||||
});
|
||||
|
||||
$connection = $this->connect();
|
||||
$raw = $connection->retrieve(1);
|
||||
|
||||
$connection->quit();
|
||||
|
||||
self::assertStringContainsString("...the end is near.\r\n", $raw);
|
||||
self::assertStringContainsString("..another dotted line\r\n", $raw);
|
||||
self::assertStringNotContainsString("....the end", $raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* The message is rebuilt with CRLF after every line, including the last.
|
||||
*
|
||||
* The parser splits headers on CRLFCRLF; a message built by joining the
|
||||
* lines without a separator is one enormous line in which no header is
|
||||
* findable and every body is empty. This is the failure that would look
|
||||
* like "POP3 stores messages with no body", and it is worth a test of its
|
||||
* own rather than being implied by the one above.
|
||||
*/
|
||||
public function testTheOriginalMessageIsRebuiltWithItsLineEndings(): void
|
||||
{
|
||||
$this->serve(static function (array $server): void {
|
||||
$server['expect']('USER someone@example.com');
|
||||
$server['send']("+OK\r\n");
|
||||
$server['expect']('PASS secret');
|
||||
$server['send']("+OK\r\n");
|
||||
|
||||
$server['expect']('RETR 1');
|
||||
$server['send']("+OK\r\nSubject: Hi\r\nFrom: a@b.c\r\n\r\nbody line\r\n.\r\n");
|
||||
});
|
||||
|
||||
$connection = $this->connect();
|
||||
$raw = $connection->retrieve(1);
|
||||
|
||||
$connection->quit();
|
||||
|
||||
self::assertSame("Subject: Hi\r\nFrom: a@b.c\r\n\r\nbody line\r\n", $raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* A DELE is not a deletion until QUIT — and abort() is the way to make sure
|
||||
* it never becomes one.
|
||||
*
|
||||
* The whole safety story of the delete mode is this. A sync that stored ten
|
||||
* messages and then died must not have told the server to drop them, and
|
||||
* the guarantee is the protocol's, not the syncer's: a session that ends
|
||||
* without QUIT rolls the transaction back.
|
||||
*/
|
||||
public function testADeletionIsOnlyRealWhenTheSessionIsQuit(): void
|
||||
{
|
||||
$this->serve(static function (array $server): void {
|
||||
$server['expect']('USER someone@example.com');
|
||||
$server['send']("+OK\r\n");
|
||||
$server['expect']('PASS secret');
|
||||
$server['send']("+OK\r\n");
|
||||
|
||||
$server['expect']('DELE 1');
|
||||
$server['send']("+OK marked\r\n");
|
||||
|
||||
// abort() closes without QUIT; the client under test must not send
|
||||
// one. Nothing to script after DELE.
|
||||
});
|
||||
|
||||
$connection = $this->connect();
|
||||
$connection->markDeleted(1, 'aaa111');
|
||||
|
||||
self::assertTrue($connection->isMarkedDeleted('aaa111'));
|
||||
|
||||
$connection->abort();
|
||||
|
||||
self::assertNotContains('QUIT', $this->received(), 'abort must not commit the transaction');
|
||||
}
|
||||
|
||||
public function testAGreetingThatIsNotOkIsAnError(): void
|
||||
{
|
||||
$this->serve(static function (array $server): void {
|
||||
$server['send']("-ERR service unavailable\r\n");
|
||||
});
|
||||
|
||||
$this->expectException(Pop3Exception::class);
|
||||
|
||||
$this->connect();
|
||||
}
|
||||
|
||||
public function testARefusedLoginIsAnError(): void
|
||||
{
|
||||
$this->serve(static function (array $server): void {
|
||||
$server['expect']('USER someone@example.com');
|
||||
$server['send']("+OK\r\n");
|
||||
|
||||
$server['expect']('PASS whatever');
|
||||
$server['send']("-ERR [AUTH] invalid credentials\r\n");
|
||||
});
|
||||
|
||||
$this->expectException(Pop3Exception::class);
|
||||
$this->expectExceptionMessageMatches('/invalid credentials/');
|
||||
|
||||
// The password in the test is the account's $password, which the
|
||||
// fixture expects; the point is the -ERR, not the value.
|
||||
Pop3Connection::open('127.0.0.1', $this->port(), 'none', 'someone@example.com', 'whatever', $this->deadline());
|
||||
}
|
||||
|
||||
public function testAMessageTheServerRefusesIsAnError(): void
|
||||
{
|
||||
$this->serve(static function (array $server): void {
|
||||
$server['expect']('USER someone@example.com');
|
||||
$server['send']("+OK\r\n");
|
||||
$server['expect']('PASS secret');
|
||||
$server['send']("+OK\r\n");
|
||||
|
||||
$server['expect']('RETR 7');
|
||||
$server['send']("-ERR no such message\r\n");
|
||||
});
|
||||
|
||||
$connection = $this->connect();
|
||||
|
||||
try {
|
||||
$this->expectException(Pop3Exception::class);
|
||||
$connection->retrieve(7);
|
||||
} finally {
|
||||
$connection->abort();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fixtures ──────────────────────────────────────────────────────────────
|
||||
|
||||
private function connect(): Pop3Connection
|
||||
{
|
||||
return Pop3Connection::open('127.0.0.1', $this->port(), 'none', 'someone@example.com', 'secret', $this->deadline());
|
||||
}
|
||||
|
||||
private function deadline(): ImapDeadline
|
||||
{
|
||||
return new ImapDeadline(new NullLogger());
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind a loopback listener and run the scripted server in a child process.
|
||||
*
|
||||
* A process rather than a thread because PHP has no threads worth using
|
||||
* here, and a forked server is exactly the shape the client sees: a peer
|
||||
* on the other end of a socket that answers when it is asked.
|
||||
*
|
||||
* @param callable(array{expect: callable, send: callable}): void $script
|
||||
*/
|
||||
private function serve(callable $script): void
|
||||
{
|
||||
$listener = stream_socket_server('tcp://127.0.0.1:0', $errno, $error);
|
||||
|
||||
if (false === $listener) {
|
||||
self::fail(sprintf('Could not bind a test listener: %s', $error));
|
||||
}
|
||||
|
||||
$this->listener = $listener;
|
||||
|
||||
// A per-test transcript file, outside the repository's var/ so a
|
||||
// parallel run does not collide with anything the app writes.
|
||||
$this->transcript = tempnam(sys_get_temp_dir(), 'pop3-transcript-') ?: '';
|
||||
|
||||
if ('' === $this->transcript) {
|
||||
self::fail('Could not create a transcript file for the scripted server.');
|
||||
}
|
||||
|
||||
register_shutdown_function(function (): void {
|
||||
if ('' !== $this->transcript && true === is_file($this->transcript)) {
|
||||
@unlink($this->transcript);
|
||||
}
|
||||
});
|
||||
|
||||
$name = stream_socket_get_name($listener, false);
|
||||
self::assertIsString($name);
|
||||
|
||||
// The port is taken from the listener's own address, so parallel runs
|
||||
// and a CI box with something on 110 are both fine.
|
||||
$this->port = (int) substr($name, strrpos($name, ':') + 1);
|
||||
|
||||
$pid = pcntl_fork();
|
||||
|
||||
|
||||
if (-1 === $pid) {
|
||||
self::markTestSkipped('needs pcntl to run a scripted server');
|
||||
}
|
||||
|
||||
if (0 === $pid) {
|
||||
// Child. Its peer connection is closed by exit; the listener is
|
||||
// inherited and closed on the way out too.
|
||||
|
||||
// Ready means "about to accept", which is before the blocking
|
||||
// call. Telling the parent from inside accept() would be a
|
||||
// deadlock: the client cannot connect until the parent returns
|
||||
// from serve(), and accept() cannot return until it does.
|
||||
touch($this->transcript . '.ready');
|
||||
|
||||
$peer = @stream_socket_accept($listener, 10);
|
||||
|
||||
|
||||
if (false === $peer) {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// The parent cannot see this process's memory, so what the client
|
||||
// sent is reported through a file. Appended one line at a time,
|
||||
// before the answer is written, so a test that reads the file after
|
||||
// the client has finished sees every command even for a session
|
||||
// the client abandoned.
|
||||
$log = fopen($this->transcript, 'ab');
|
||||
|
||||
$server = [
|
||||
'expect' => static function (string $line) use ($peer, $log): void {
|
||||
$answer = fgets($peer);
|
||||
|
||||
if (false === $answer) {
|
||||
exit(2);
|
||||
}
|
||||
|
||||
$got = rtrim($answer, "\r\n");
|
||||
|
||||
fwrite($log, $got . "\n");
|
||||
fflush($log);
|
||||
|
||||
if ($got !== $line) {
|
||||
exit(3);
|
||||
}
|
||||
},
|
||||
'send' => static function (string $data) use ($peer): void {
|
||||
fwrite($peer, $data);
|
||||
},
|
||||
];
|
||||
|
||||
fwrite($peer, "+OK pop3.test ready\r\n");
|
||||
|
||||
try {
|
||||
$script($server);
|
||||
} catch (\Throwable $e) {
|
||||
fwrite(STDERR, "CHILD script threw: " . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
|
||||
fclose($peer);
|
||||
fclose($log);
|
||||
fclose($listener);
|
||||
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$this->child = $pid;
|
||||
|
||||
// Wait until the child is inside accept(). Without this the parent can
|
||||
// connect, send nothing, and the child's inherited copy of the
|
||||
// listener is what answers — or, worse, accept() has not been reached
|
||||
// yet and the client's connect lands in the backlog until it does.
|
||||
// The child appends a byte to a second file the moment it is ready.
|
||||
$readyFile = $this->transcript . '.ready';
|
||||
$waited = 0.0;
|
||||
|
||||
while (false === is_file($readyFile) && $waited < 5.0) {
|
||||
usleep(20_000);
|
||||
$waited += 0.02;
|
||||
}
|
||||
|
||||
@unlink($readyFile);
|
||||
|
||||
register_shutdown_function(function (): void {
|
||||
// The script ends the child itself when the client quits; this is
|
||||
// the belt for a test that fails before then.
|
||||
if (null !== $this->child) {
|
||||
$status = 0;
|
||||
|
||||
pcntl_waitpid($this->child, $status, WNOHANG);
|
||||
$this->child = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private ?int $child = null;
|
||||
|
||||
/** What the client sent, as the child recorded it. */
|
||||
private string $transcript = '';
|
||||
|
||||
/** @return list<string> every command the client sent, in order */
|
||||
private function received(): array
|
||||
{
|
||||
if ('' === $this->transcript || false === is_file($this->transcript)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
explode("\n", (string) file_get_contents($this->transcript)),
|
||||
static fn (string $line): bool => '' !== $line,
|
||||
));
|
||||
}
|
||||
|
||||
private int $port = 0;
|
||||
|
||||
private function port(): int
|
||||
{
|
||||
return $this->port;
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,7 @@ final class MailServerHostTest extends TestCase
|
||||
new \RuntimeException('* OK Dovecot (Ubuntu) 2.3.16 ready, AUTHENTICATIONFAILED Authentication failed.'),
|
||||
));
|
||||
|
||||
$result = new ConnectionTester($imap, new SmtpDsnFactory(), new IdentityTranslator(), new NullLogger())->test($account);
|
||||
$result = new ConnectionTester($imap, new SmtpDsnFactory(), new IdentityTranslator(), new NullLogger(), new \App\Service\Imap\ImapDeadline(new NullLogger()))->test($account);
|
||||
|
||||
self::assertFalse($result->imapOk);
|
||||
self::assertSame('account.test.result.auth', $result->imapMessage);
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service\Pop3;
|
||||
|
||||
use App\Domain\Enum\Account\IncomingProtocol;
|
||||
use App\Domain\Enum\Mail\LabelRole;
|
||||
use App\Domain\Enum\Mail\MailboxSpecialUse;
|
||||
use App\Entity\Mail\Account;
|
||||
use App\Entity\Mail\Mailbox;
|
||||
use App\Entity\Mail\Message;
|
||||
use App\Entity\Mail\MessageThread;
|
||||
use App\Domain\Enum\Mail\ThreadingMethod;
|
||||
use App\Entity\User\User;
|
||||
use App\Repository\Mail\MailboxRepository;
|
||||
use App\Service\Label\LabelResolver;
|
||||
use App\Service\Pop3\Pop3AccountSyncer;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
/**
|
||||
* A POP3 account's mailbox is bound to the Inbox label.
|
||||
*
|
||||
* The bug this exists for was found by deploying the feature, not by the suite:
|
||||
* five messages synced into a mailbox that had no label binding, so they were
|
||||
* in the database and visible nowhere. Every view in the application — the
|
||||
* Inbox list, the sidebar counts, the JMAP Mailbox object — reads the binding,
|
||||
* and MessageSyncer::buildMessageFromParsed() attaches `$mailbox->label`,
|
||||
* which was null, so the messages carried no label at all.
|
||||
*
|
||||
* IMAP never hits it: MailboxSyncer::linkLabel() binds every folder it
|
||||
* creates. This is POP3's equivalent, and it has to hold both for a mailbox
|
||||
* created fresh and for one that already existed without a binding — the
|
||||
* second is what a deployment that ran the broken code actually has.
|
||||
*/
|
||||
final class Pop3MailboxLabelTest extends KernelTestCase
|
||||
{
|
||||
private EntityManagerInterface $em;
|
||||
private User $user;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
|
||||
$this->em = static::getContainer()->get('doctrine.orm.entity_manager');
|
||||
$this->em->getConnection()->beginTransaction();
|
||||
|
||||
$this->user = new User();
|
||||
$this->user->email = 'pop3-label-' . bin2hex(random_bytes(5)) . '@plmail.test';
|
||||
$this->user->nameFirst = 'Pop3';
|
||||
$this->user->nameLast = 'Label';
|
||||
$this->user->password = 'x';
|
||||
$this->em->persist($this->user);
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (true === $this->em->getConnection()->isTransactionActive()) {
|
||||
$this->em->getConnection()->rollBack();
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* A mailbox this syncer creates comes out bound to the account's Inbox label.
|
||||
*
|
||||
* Reflection is used to reach the private factory rather than driving a
|
||||
* whole sync: the property under test is "the mailbox it makes is bound",
|
||||
* and a sync would need a live POP3 server to get anywhere near it. The
|
||||
* class is final and readonly, so the collaborators it needs are the real
|
||||
* ones from the container.
|
||||
*/
|
||||
public function testAFreshMailboxIsBoundToTheInboxLabel(): void
|
||||
{
|
||||
$account = $this->pop3Account();
|
||||
$syncer = static::getContainer()->get(Pop3AccountSyncer::class);
|
||||
|
||||
$mailbox = $this->invokeMailboxFor($syncer, $account);
|
||||
|
||||
self::assertSame(MailboxSpecialUse::INBOX, $mailbox->specialUse);
|
||||
self::assertNotNull(
|
||||
$mailbox->label,
|
||||
'a Mailbox with no label is invisible everywhere; messages filed into it get no label either',
|
||||
);
|
||||
self::assertSame(LabelRole::Inbox, $mailbox->label->role);
|
||||
}
|
||||
|
||||
/**
|
||||
* A mailbox that already existed without a binding is healed.
|
||||
*
|
||||
* The deployed instance has exactly this shape: the broken code created the
|
||||
* row and stored five messages against it. Re-running only has to fix the
|
||||
* binding, not recreate anything.
|
||||
*/
|
||||
public function testAnUnboundMailboxIsBoundOnTheNextRun(): void
|
||||
{
|
||||
$account = $this->pop3Account();
|
||||
|
||||
$orphan = new Mailbox();
|
||||
$orphan->account = $account;
|
||||
$orphan->name = 'Inbox';
|
||||
$orphan->fullPath = 'INBOX';
|
||||
$orphan->specialUse = MailboxSpecialUse::INBOX;
|
||||
$orphan->isSelectable = true;
|
||||
$orphan->isSyncEnabled = true;
|
||||
$this->em->persist($orphan);
|
||||
$this->em->flush();
|
||||
|
||||
self::assertNull($orphan->label, 'precondition: the row starts unbound');
|
||||
|
||||
$syncer = static::getContainer()->get(Pop3AccountSyncer::class);
|
||||
$mailbox = $this->invokeMailboxFor($syncer, $account);
|
||||
|
||||
self::assertSame($orphan->id, $mailbox->id, 'no second mailbox may be created');
|
||||
self::assertNotNull($mailbox->label, 'the existing row must be healed, not replaced');
|
||||
self::assertSame(LabelRole::Inbox, $mailbox->label->role);
|
||||
}
|
||||
|
||||
/**
|
||||
* Messages already sitting in the mailbox with no label get the Inbox one.
|
||||
*
|
||||
* The half the deployment exposed: binding the mailbox is not enough,
|
||||
* because a message's labels are set when it is built and never revisited.
|
||||
* A message whose UIDL is in the ledger is never fetched again, so without
|
||||
* this it stays invisible for ever.
|
||||
*/
|
||||
public function testMessagesWithNoLabelsAreGivenTheInboxLabel(): void
|
||||
{
|
||||
$account = $this->pop3Account();
|
||||
$syncer = static::getContainer()->get(Pop3AccountSyncer::class);
|
||||
|
||||
// First call creates and binds the mailbox.
|
||||
$mailbox = $this->invokeMailboxFor($syncer, $account);
|
||||
|
||||
// Two messages filed into it with no labels, as the broken code left them.
|
||||
$orphanA = $this->message($account, $mailbox, 'orphan-a');
|
||||
$orphanB = $this->message($account, $mailbox, 'orphan-b');
|
||||
|
||||
// One that carries a label of its own — filed by the user — and must be
|
||||
// left alone: adding Inbox to it would pull it back into the inbox.
|
||||
$filed = $this->message($account, $mailbox, 'filed-elsewhere');
|
||||
$other = static::getContainer()->get(LabelResolver::class)
|
||||
->systemLabel(LabelRole::Archive, $account);
|
||||
$filed->addLabel($other);
|
||||
$this->em->flush();
|
||||
|
||||
$this->invokeMailboxFor($syncer, $account);
|
||||
|
||||
$this->em->refresh($orphanA);
|
||||
$this->em->refresh($orphanB);
|
||||
$this->em->refresh($filed);
|
||||
|
||||
self::assertCount(1, $orphanA->labels, 'an unlabelled message gets exactly the Inbox label');
|
||||
self::assertSame(LabelRole::Inbox, $orphanA->labels->first()->role);
|
||||
self::assertCount(1, $orphanB->labels);
|
||||
|
||||
self::assertCount(1, $filed->labels, 'a filed message keeps only the label it was given');
|
||||
self::assertSame(LabelRole::Archive, $filed->labels->first()->role);
|
||||
}
|
||||
|
||||
/**
|
||||
* The repair reaches the THREAD too, which is what the inbox actually lists.
|
||||
*
|
||||
* The second half of the same deployment finding: a message labelled while
|
||||
* its conversation carries no label is still invisible, because the inbox
|
||||
* query joins the thread's labels rather than the message's.
|
||||
*/
|
||||
public function testTheRepairAlsoLabelsTheThread(): void
|
||||
{
|
||||
$account = $this->pop3Account();
|
||||
$syncer = static::getContainer()->get(Pop3AccountSyncer::class);
|
||||
|
||||
$mailbox = $this->invokeMailboxFor($syncer, $account);
|
||||
|
||||
$orphan = $this->message($account, $mailbox, 'thread-orphan');
|
||||
$thread = new MessageThread();
|
||||
$thread->account = $account;
|
||||
$thread->threadingMethod = ThreadingMethod::SubjectFallback;
|
||||
$thread->normalizedSubject = 'fixture';
|
||||
$thread->lastMessageAt = new \DateTimeImmutable();
|
||||
$this->em->persist($thread);
|
||||
$orphan->thread = $thread;
|
||||
$this->em->flush();
|
||||
|
||||
self::assertCount(0, $thread->labels, 'precondition: the conversation carries no label');
|
||||
|
||||
$this->invokeMailboxFor($syncer, $account);
|
||||
|
||||
$this->em->refresh($thread);
|
||||
|
||||
self::assertCount(1, $thread->labels, 'the inbox lists threads; a thread with no label is invisible');
|
||||
self::assertSame(LabelRole::Inbox, $thread->labels->first()->role);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private function message(Account $account, Mailbox $mailbox, string $tag): Message
|
||||
{
|
||||
$message = new Message();
|
||||
$message->account = $account;
|
||||
$message->mailbox = $mailbox;
|
||||
$message->messageId = $tag . '-' . bin2hex(random_bytes(4)) . '@plmail.test';
|
||||
$message->subject = 'Fixture ' . $tag;
|
||||
$message->fromAddress = 'sender@example.test';
|
||||
$message->receivedAt = new \DateTimeImmutable();
|
||||
$message->syncedAt = new \DateTimeImmutable();
|
||||
$message->hasAttachments = false;
|
||||
|
||||
$this->em->persist($message);
|
||||
$this->em->flush();
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
private function pop3Account(): Account
|
||||
{
|
||||
$account = new Account();
|
||||
$account->usr = $this->user;
|
||||
$account->name = 'POP3 label fixture';
|
||||
$account->email = 'pop3-' . bin2hex(random_bytes(4)) . '@plmail.test';
|
||||
$account->username = $account->email;
|
||||
$account->incomingProtocol = IncomingProtocol::Pop3;
|
||||
$account->imapHost = 'pop.example.test';
|
||||
$account->imapPort = 995;
|
||||
$account->imapEncryption = 'ssl';
|
||||
$account->authType = 'password';
|
||||
$account->isActive = true;
|
||||
|
||||
$this->em->persist($account);
|
||||
$this->user->addAccount($account);
|
||||
$this->em->flush();
|
||||
|
||||
return $account;
|
||||
}
|
||||
|
||||
private function invokeMailboxFor(Pop3AccountSyncer $syncer, Account $account): Mailbox
|
||||
{
|
||||
$method = new \ReflectionMethod($syncer, 'mailboxFor');
|
||||
$method->setAccessible(true);
|
||||
|
||||
/** @var Mailbox $mailbox */
|
||||
$mailbox = $method->invoke($syncer, $account);
|
||||
|
||||
$this->em->refresh($mailbox);
|
||||
|
||||
return $mailbox;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,15 @@ account:
|
||||
host: Server
|
||||
password: Passwort
|
||||
port: Port
|
||||
protocol:
|
||||
label: Eingehendes Protokoll
|
||||
imap: IMAP
|
||||
pop3: POP3
|
||||
pop3:
|
||||
delete_label: Nach dem Abruf
|
||||
delete_on_fetch: Nachricht auf dem Server löschen
|
||||
keep_on_server: Auf dem Server belassen (empfohlen)
|
||||
delete_help: POP3 hat keine Ordner und kein Push. Eine auf dem Server belassene Nachricht wird beim nächsten Abgleich über ihre UIDL erkannt und nicht erneut geladen. Löschen schafft Speicherplatz auf dem Server, entfernt aber die einzige zweite Kopie.
|
||||
preset:
|
||||
label: Anbieter
|
||||
placeholder: Anbieter wählen…
|
||||
@@ -45,6 +54,7 @@ account:
|
||||
failed: 'Die Verbindung hat nicht geklappt. Prüf Host, Port und Verschlüsselung.'
|
||||
host_invalid: 'Gib nur einen Hostnamen oder eine IP-Adresse ein — ohne Schrägstriche, Fragezeichen oder @.'
|
||||
imap_ok: 'Verbunden — %count% Ordner sichtbar.'
|
||||
pop3_ok: 'Verbunden — %count% Nachrichten im Postfach.'
|
||||
missing_fields: 'Gib zuerst mindestens eine E-Mail-Adresse und einen IMAP-Host ein.'
|
||||
missing_password: 'Kein Passwort zum Testen. Gib oben eins ein — beim Bearbeiten behält ein leeres Feld das gespeicherte Passwort, und das kann der Test erst nutzen, wenn das Konto gespeichert ist.'
|
||||
smtp_none: 'Kein SMTP-Host eingetragen — Senden ist für dieses Konto aus.'
|
||||
|
||||
@@ -12,6 +12,15 @@ account:
|
||||
host: Host
|
||||
password: Password
|
||||
port: Port
|
||||
protocol:
|
||||
label: Incoming protocol
|
||||
imap: IMAP
|
||||
pop3: POP3
|
||||
pop3:
|
||||
delete_label: After fetching
|
||||
delete_on_fetch: Delete the message on the server
|
||||
keep_on_server: Leave it on the server (recommended)
|
||||
delete_help: POP3 has no folders and no push. A message left on the server is recognised next sync by its UIDL and is not fetched twice. Deleting frees the server's quota but removes the only second copy.
|
||||
preset:
|
||||
label: Provider
|
||||
placeholder: Choose a provider…
|
||||
@@ -45,6 +54,7 @@ account:
|
||||
failed: 'The connection did not work. Check the host, port and encryption.'
|
||||
host_invalid: 'Enter a host name or IP address only — no slashes, question marks or @.'
|
||||
imap_ok: 'Connected — %count% folders visible.'
|
||||
pop3_ok: 'Connected — %count% messages in the mailbox.'
|
||||
missing_fields: 'Enter at least an email address and an IMAP host first.'
|
||||
missing_password: 'No password to test with. Enter one above — on the edit form a blank field keeps the stored password, which the test can only use once the account is saved.'
|
||||
smtp_none: 'No SMTP host configured — sending is disabled for this account.'
|
||||
|
||||
@@ -12,6 +12,15 @@ account:
|
||||
host: Port o' call
|
||||
password: Secret word
|
||||
port: Berth number
|
||||
protocol:
|
||||
label: Incomin' protocol
|
||||
imap: IMAP
|
||||
pop3: POP3
|
||||
pop3:
|
||||
delete_label: After haulin' it in
|
||||
delete_on_fetch: Send the message to Davy Jones on the server
|
||||
keep_on_server: Leave it in the port (recommended)
|
||||
delete_help: POP3 has no folders and no hailin'. A message left in port be spotted next time by its UIDL and not hauled in twice. Sendin' it overboard frees the harbour's space but scuttles the only other copy.
|
||||
preset:
|
||||
label: Harbourmaster
|
||||
placeholder: Pick yer harbour…
|
||||
@@ -45,6 +54,7 @@ account:
|
||||
failed: 'The line didn''t hold. Check the host, port an'' encryption.'
|
||||
host_invalid: 'A host name or IP address only, sailor — no slashes, question marks nor @.'
|
||||
imap_ok: 'Aboard — %count% holds in sight.'
|
||||
pop3_ok: 'Aye, connected — %count% messages in the hold.'
|
||||
missing_fields: 'Give us an email address an'' an IMAP host first.'
|
||||
missing_password: 'No password to test with. Enter one above — on the edit form a blank field keeps the stored one, which the test can only use once the ship be saved.'
|
||||
smtp_none: 'No SMTP host charted — this ship sends no letters.'
|
||||
|
||||
@@ -12,6 +12,15 @@ account:
|
||||
host: Serveur
|
||||
password: Mot de passe
|
||||
port: Port
|
||||
protocol:
|
||||
label: Protocole de réception
|
||||
imap: IMAP
|
||||
pop3: POP3
|
||||
pop3:
|
||||
delete_label: Après le relève
|
||||
delete_on_fetch: Supprimer le message sur le serveur
|
||||
keep_on_server: Le laisser sur le serveur (recommandé)
|
||||
delete_help: POP3 n'a ni dossiers ni push. Un message laissé sur le serveur est reconnu à la prochaine synchronisation par son UIDL et n'est pas relevé deux fois. Le supprimer libère du quota sur le serveur mais retire la seule seconde copie.
|
||||
preset:
|
||||
label: Fournisseur
|
||||
placeholder: Choisissez un fournisseur…
|
||||
@@ -45,6 +54,7 @@ account:
|
||||
failed: 'La connexion n’a pas fonctionné. Vérifiez le serveur, le port et le chiffrement.'
|
||||
host_invalid: 'Saisissez uniquement un nom d’hôte ou une adresse IP — sans barre oblique, point d’interrogation ni @.'
|
||||
imap_ok: 'Connecté — %count% dossiers visibles.'
|
||||
pop3_ok: 'Connecté — %count% messages dans la boîte.'
|
||||
missing_fields: 'Saisissez d’abord au moins une adresse e-mail et un serveur IMAP.'
|
||||
missing_password: 'Aucun mot de passe pour le test. Saisissez-en un ci-dessus — dans le formulaire de modification, un champ vide conserve le mot de passe enregistré, que le test ne peut utiliser qu’une fois le compte enregistré.'
|
||||
smtp_none: 'Aucun serveur SMTP configuré — l’envoi est désactivé pour ce compte.'
|
||||
|
||||
Reference in new issue
Block a user