Release v0.1.2

This commit is contained in:
2026-06-25 19:58:20 +02:00
parent 39ad3500e2
commit 23318c709a
98 changed files with 3432 additions and 2339 deletions

View File

@@ -28,11 +28,15 @@ from govoplan_campaign.backend.db.models import (
SendAttempt,
)
from govoplan_campaign.backend.sending.execution import ExecutionSnapshot, ExecutionSnapshotError, ensure_execution_snapshot, runtime_imap_config, runtime_smtp_config
from govoplan_mail.backend.mail_profiles import MailProfileError, assert_mail_policy_allows_send
from govoplan_mail.backend.sending.rate_limit import wait_for_rate_limit
from govoplan_mail.backend.sending.smtp import SmtpConfigurationError, SmtpSendError, send_email_bytes
from govoplan_mail.backend.sending.imap import ImapAppendError, ImapConfigurationError, append_message_to_sent
from govoplan_files.backend.storage.services import mark_job_attachment_uses_sent
from govoplan_campaign.backend.integrations import (
ImapAppendError,
ImapConfigurationError,
MailProfileError,
SmtpConfigurationError,
SmtpSendError,
files_integration,
mail_integration,
)
class QueueingError(RuntimeError):
@@ -709,7 +713,7 @@ def reconcile_job_outcome(
job.claim_token = None
job.last_error = note or "Operator confirmed SMTP acceptance after an uncertain outcome."
job.imap_status = JobImapStatus.PENDING.value if snapshot.delivery.imap_append_sent.enabled else JobImapStatus.NOT_REQUESTED.value
mark_job_attachment_uses_sent(session, job)
files_integration().mark_job_attachment_uses_sent(session, job)
attempt_status = "reconciled_smtp_accepted"
elif decision == "not_sent":
job.send_status = JobSendStatus.FAILED_TEMPORARY.value
@@ -1054,7 +1058,7 @@ def send_campaign_job(
job = session.get(CampaignJob, job.id)
assert job is not None
wait_for_rate_limit(
mail_integration().wait_for_rate_limit(
key=f"tenant:{job.tenant_id}:campaign:{job.campaign_id}",
messages_per_minute=snapshot.delivery.rate_limit.messages_per_minute,
enabled=use_rate_limit,
@@ -1062,7 +1066,7 @@ def send_campaign_job(
attempt = _record_attempt_start(session, job, claim_token)
try:
smtp_config = runtime_smtp_config(session, version, snapshot)
assert_mail_policy_allows_send(
mail_integration().assert_mail_policy_allows_send(
session,
tenant_id=job.tenant_id,
campaign_id=job.campaign_id,
@@ -1071,7 +1075,7 @@ def send_campaign_job(
from_header=_from_header_from_job(job, snapshot),
recipients=envelope_recipients,
)
result = send_email_bytes(
result = mail_integration().send_email_bytes(
message_bytes,
smtp_config=smtp_config,
envelope_from=envelope_from,
@@ -1098,7 +1102,7 @@ def send_campaign_job(
else:
job.imap_status = JobImapStatus.NOT_REQUESTED.value
job.last_error = refused_warning
mark_job_attachment_uses_sent(session, job)
files_integration().mark_job_attachment_uses_sent(session, job)
session.add(attempt)
session.add(job)
_update_campaign_after_job(session, job.campaign_id, job.campaign_version_id)
@@ -1208,9 +1212,9 @@ def append_sent_for_job(session: Session, *, job_id: str, dry_run: bool = False)
session.commit()
return AppendSentResult(job_id=job.id, status="not_requested", attempt_number=0, dry_run=dry_run)
imap_config = runtime_imap_config(session, version, snapshot)
if not imap_config or not imap_config.enabled:
if not imap_config:
job.imap_status = JobImapStatus.SKIPPED.value
job.last_error = "IMAP append requested, but the execution snapshot has no enabled IMAP configuration"
job.last_error = "IMAP append requested, but the execution snapshot has no IMAP configuration"
session.add(job)
session.commit()
return AppendSentResult(job_id=job.id, status="skipped", attempt_number=0, dry_run=dry_run, message=job.last_error)
@@ -1230,14 +1234,14 @@ def append_sent_for_job(session: Session, *, job_id: str, dry_run: bool = False)
attempt = _record_imap_attempt_start(session, job)
try:
assert_mail_policy_allows_send(
mail_integration().assert_mail_policy_allows_send(
session,
tenant_id=job.tenant_id,
campaign_id=job.campaign_id,
smtp=snapshot.smtp,
imap=imap_config,
)
result = append_message_to_sent(
result = mail_integration().append_message_to_sent(
message_bytes,
imap_config=imap_config,
folder=None if folder == "auto" else folder,
@@ -1246,7 +1250,7 @@ def append_sent_for_job(session: Session, *, job_id: str, dry_run: bool = False)
attempt.folder = result.folder
job.imap_status = JobImapStatus.APPENDED.value
job.last_error = None
mark_job_attachment_uses_sent(session, job)
files_integration().mark_job_attachment_uses_sent(session, job)
session.add(attempt)
session.add(job)
session.commit()
@@ -1263,7 +1267,15 @@ def append_sent_for_job(session: Session, *, job_id: str, dry_run: bool = False)
raise
def enqueue_pending_imap_appends(session: Session, *, tenant_id: str, campaign_id: str, enqueue_celery: bool = True, dry_run: bool = False) -> dict[str, Any]:
def enqueue_pending_imap_appends(
session: Session,
*,
tenant_id: str,
campaign_id: str,
enqueue_celery: bool = True,
run_inline: bool = False,
dry_run: bool = False,
) -> dict[str, Any]:
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
jobs = (
session.query(CampaignJob)
@@ -1276,15 +1288,39 @@ def enqueue_pending_imap_appends(session: Session, *, tenant_id: str, campaign_i
.order_by(CampaignJob.entry_index.asc())
.all()
)
should_enqueue = _should_enqueue_celery(enqueue_celery) and not dry_run
if should_enqueue:
should_enqueue = _should_enqueue_celery(enqueue_celery) and not dry_run and not run_inline
results: list[dict[str, Any]] = []
appended_count = 0
failed_count = 0
skipped_count = 0
if run_inline or dry_run:
for job in jobs:
try:
result = append_sent_for_job(session, job_id=job.id, dry_run=dry_run)
payload = result.as_dict()
results.append(payload)
if result.status == JobImapStatus.APPENDED.value:
appended_count += 1
elif result.status in {"skipped", "not_requested", "not_sent", "already_appended", "dry_run"}:
skipped_count += 1
except Exception as exc: # keep processing later jobs and expose per-job details
failed_count += 1
results.append({"job_id": job.id, "status": "failed", "message": str(exc)})
elif should_enqueue:
for job in jobs:
_celery_enqueue_append_sent_job(job.id)
return {
"campaign_id": campaign.id,
"pending_count": len(jobs),
"enqueued_count": len(jobs) if should_enqueue else 0,
"processed_count": len(results) if run_inline and not dry_run else 0,
"appended_count": appended_count,
"failed_count": failed_count,
"skipped_count": skipped_count,
"dry_run": dry_run,
"run_inline": run_inline,
"results": results,
}
def next_retry_delay(snapshot: ExecutionSnapshot, attempt_count: int) -> int: