Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
5190d12
Add postcard sending
aapeliv Mar 15, 2026
5336660
Add postcard service tests and MyPostcard API collection
aapeliv Mar 15, 2026
a96e398
Fix mypy errors in postcard service
aapeliv Mar 15, 2026
4f62aa8
Use static postcard front image and simplify service
aapeliv Mar 18, 2026
94ef9a0
Iterate on postcard sending
aapeliv Mar 20, 2026
b1599eb
Update tests
aapeliv Mar 23, 2026
a959b5c
Fix tests and align with repo conventions
aapeliv Mar 23, 2026
8652bd3
Fix failing tests: handle process_job exception and fix mock strategy
aapeliv Mar 25, 2026
ba51f61
Clean up postcard verification: add sentry module, use int job_id, in…
aapeliv Mar 25, 2026
d6c572c
Fix indentation error and import sorting from CI failures
aapeliv Mar 26, 2026
3f04860
Bump migration to 0141 after rebase onto develop
aapeliv Mar 26, 2026
666cf49
Rename country to country_code in postal verification model
aapeliv Mar 26, 2026
4d6d7ca
Move country→country_code rename from 0118 to 0141 migration
aapeliv Mar 28, 2026
3732474
Add postcard image generation test and CI artifact upload
aapeliv Mar 28, 2026
b1b84e7
Remove hardcoded QR version=1, let library auto-detect
aapeliv Mar 29, 2026
1c87024
Clean up postcard generation: hardcode font size, inline variables, c…
aapeliv Mar 29, 2026
71a72d4
Rename country to country_code in postal verification proto and use U…
aapeliv Mar 30, 2026
d70b981
Clean up
aapeliv Mar 30, 2026
5840b3c
Address review comments
aapeliv Mar 31, 2026
1abcc8d
Address review comments
aapeliv Apr 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions app/.gitlab-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -320,12 +320,15 @@ test:backend:
- cp /app/junit.xml $CI_PROJECT_DIR/junit-$CI_NODE_INDEX.xml
- cp /app/.coverage.$CI_NODE_INDEX $CI_PROJECT_DIR/
- cp /app/.test_durations.$CI_NODE_INDEX $CI_PROJECT_DIR/
- mkdir -p $CI_PROJECT_DIR/artifacts/test_artifacts
- cp /app/test_artifacts/* $CI_PROJECT_DIR/artifacts/test_artifacts/ 2>/dev/null || true
artifacts:
reports:
junit: junit-$CI_NODE_INDEX.xml
paths:
- .coverage.$CI_NODE_INDEX
- .test_durations.$CI_NODE_INDEX
- artifacts/test_artifacts
rules:
- if: ($DO_CHECKS == "true") && ($CI_COMMIT_BRANCH == $RELEASE_BRANCH)
- if: ($DO_CHECKS == "true") && ($CI_COMMIT_BRANCH != $RELEASE_BRANCH)
Expand Down Expand Up @@ -680,6 +683,8 @@ preview:backend-coverage:

preview:backend:
needs:
- job: test:backend
artifacts: true
- job: test:backend-migrations
artifacts: true
- job: preview:backend-coverage
Expand Down Expand Up @@ -716,9 +721,16 @@ preview:backend:
- aws s3 rm s3://$AWS_PREVIEW_BUCKET/schema/$CI_COMMIT_REF_SLUG/ --recursive
- aws s3 cp artifacts/schema_dumps s3://$AWS_PREVIEW_BUCKET/schema/$CI_COMMIT_REF_SLUG/ --recursive
- echo "Done, schema dumps available at https://$CI_COMMIT_SHORT_SHA--schema.$PREVIEW_DOMAIN/schema.sql and https://$CI_COMMIT_REF_SLUG--schema.$PREVIEW_DOMAIN/schema.sql"
# Upload postcard samples
- aws s3 rm s3://$AWS_PREVIEW_BUCKET/test-artifacts/$CI_COMMIT_SHORT_SHA/ --recursive
- aws s3 cp artifacts/test_artifacts s3://$AWS_PREVIEW_BUCKET/test-artifacts/$CI_COMMIT_SHORT_SHA/ --recursive 2>/dev/null || true
- aws s3 rm s3://$AWS_PREVIEW_BUCKET/test-artifacts/$CI_COMMIT_REF_SLUG/ --recursive
- aws s3 cp artifacts/test_artifacts s3://$AWS_PREVIEW_BUCKET/test-artifacts/$CI_COMMIT_REF_SLUG/ --recursive 2>/dev/null || true
- echo "Done, test artifacts available at https://$CI_COMMIT_SHORT_SHA--test-artifacts.$PREVIEW_DOMAIN/ and https://$CI_COMMIT_REF_SLUG--test-artifacts.$PREVIEW_DOMAIN/"
artifacts:
paths:
- artifacts/schema_dumps
- artifacts/test_artifacts
rules:
- if: ($DO_CHECKS == "true") && ($CI_COMMIT_BRANCH == $RELEASE_BRANCH)
- if: ($DO_CHECKS == "true") && ($CI_COMMIT_BRANCH != $RELEASE_BRANCH)
Expand Down
5 changes: 5 additions & 0 deletions app/backend.dev.env
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ IRIS_ID_SECRET=secret-...
VERIFICATION_DATA_PUBLIC_KEY=dd740a2b2a35bf05041a28257ea439b30f76f056f3698000b71e6470cd82275f

ENABLE_POSTAL_VERIFICATION=0
MYPOSTCARD_API_KEY=...
MYPOSTCARD_USERNAME=...
MYPOSTCARD_PASSWORD=...
MYPOSTCARD_PRODUCT_CODE=J9GCU
MYPOSTCARD_CAMPAIGN_ID=295

ENABLE_SMS=0
SMS_SENDER_ID=invalid
Expand Down
3 changes: 3 additions & 0 deletions app/backend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,6 @@ src/proto
# coverage reports
.coverage
htmlcov/

# test artifacts
test_artifacts/
3 changes: 3 additions & 0 deletions app/backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,14 @@ dependencies = [
"opentelemetry-instrumentation-sqlalchemy>=0.50b0",
"opentelemetry-instrumentation-threading>=0.50b0",
"opentelemetry-sdk>=1.29.0",
"Pillow>=11.0.0",
"phonenumbers>=9.0.4",
"prometheus-client>=0.23.1",
"protobuf>=6.33.1",
"psycopg2-binary>=2.9.10",
"py-vapid>=1.9.2",
"pynacl>=1.5.0",
"qrcode>=8.0",
"python-dateutil>=2.9.0.post0",
"pytz>=2025.2",
"pyyaml>=6.0.3",
Expand Down Expand Up @@ -63,6 +65,7 @@ dev = [
"types-psycopg2",
"types-python-dateutil>=2.9.0.20251115",
"pytest-split>=0.11.0",
"types-qrcode>=8.2.0.20250914",
]


Expand Down
Binary file added app/backend/resources/hack-bold.ttf
Comment thread
aapeliv marked this conversation as resolved.
Binary file not shown.
Binary file added app/backend/resources/postcard-back-left.png
Comment thread
aapeliv marked this conversation as resolved.
Comment thread
aapeliv marked this conversation as resolved.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added app/backend/resources/postcard-front.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 10 additions & 0 deletions app/backend/resources/postcard-metadata.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"back_left": {
"qr_left": 222,
"qr_top": 414,
"qr_size": 322,
"code_center_x": 383,
"code_center_y": 801,
"code_font_size": 58
}
}
16 changes: 16 additions & 0 deletions app/backend/src/couchers/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@
("VERIFICATION_DATA_PUBLIC_KEY", bytes),
# Postal verification
("ENABLE_POSTAL_VERIFICATION", bool),
# MyPostcard API credentials
("MYPOSTCARD_API_KEY", str),
("MYPOSTCARD_USERNAME", str),
("MYPOSTCARD_PASSWORD", str),
("MYPOSTCARD_PRODUCT_CODE", str),
("MYPOSTCARD_CAMPAIGN_ID", str),
# SMS
("ENABLE_SMS", bool),
("SMS_SENDER_ID", str),
Expand Down Expand Up @@ -153,6 +159,16 @@ def check_config(cfg: dict[str, Any]) -> None:
if not cfg["IRIS_ID_PUBKEY"] or not cfg["IRIS_ID_SECRET"] or not cfg["VERIFICATION_DATA_PUBLIC_KEY"]:
raise Exception("No Iris ID pubkey/secret or verification data pubkey but strong verification enabled")

if cfg["ENABLE_POSTAL_VERIFICATION"]:
if (
not cfg["MYPOSTCARD_API_KEY"]
or not cfg["MYPOSTCARD_USERNAME"]
or not cfg["MYPOSTCARD_PASSWORD"]
or not cfg["MYPOSTCARD_PRODUCT_CODE"]
or not cfg["MYPOSTCARD_CAMPAIGN_ID"]
):
raise Exception("MyPostcard API credentials not configured but postal verification enabled")

if cfg["EXPERIMENTATION_ENABLED"]:
if not cfg["STATSIG_SERVER_SECRET_KEY"]:
raise Exception("No Statsig server secret key but experimentation enabled")
Expand Down
2 changes: 2 additions & 0 deletions app/backend/src/couchers/jobs/definitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
auto_approve_moderation_queue,
check_database_consistency,
check_expo_push_receipts,
check_mypostcard_jobs,
enforce_community_membership,
finalize_strong_verification,
purge_account_deletion_tokens,
Expand Down Expand Up @@ -99,6 +100,7 @@ def payload_type(self) -> type[T]:
Job(check_expo_push_receipts, schedule=timedelta(minutes=5)),
Job(send_postal_verification_postcard),
Job(check_database_consistency, schedule=timedelta(hours=24)),
Job(check_mypostcard_jobs, schedule=timedelta(hours=24)),
Job(auto_approve_moderation_queue, schedule=timedelta(seconds=15)),
]

Expand Down
84 changes: 63 additions & 21 deletions app/backend/src/couchers/jobs/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@
update,
)

from couchers import urls
from couchers.config import config
from couchers.constants import (
ACTIVENESS_PROBE_EXPIRY_TIME,
Expand All @@ -51,13 +50,15 @@
from couchers.db import session_scope
from couchers.email.dev import print_dev_email
from couchers.email.smtp import send_smtp_email
from couchers.event_log import log_event
from couchers.helpers.badges import user_add_badge, user_remove_badge
from couchers.helpers.completed_profile import has_completed_profile_expression
from couchers.materialized_views import (
UserResponseRate,
)
from couchers.metrics import (
moderation_auto_approved_counter,
postcards_sent_counter,
push_notification_counter,
strong_verification_completions_counter,
)
Expand Down Expand Up @@ -102,10 +103,11 @@
from couchers.models.notifications import NotificationTopicAction
from couchers.notifications.expo_api import get_expo_push_receipts
from couchers.notifications.notify import notify
from couchers.postal.postcard_service import send_postcard
from couchers.postal.my_postcard import get_order_ids, send_postcard
from couchers.proto import moderation_pb2, notification_data_pb2
from couchers.proto.internal import internal_pb2, jobs_pb2
from couchers.resources import get_badge_dict, get_static_badge_dict
from couchers.sentry import report_message
from couchers.servicers.api import user_model_to_pb
from couchers.servicers.events import (
event_to_pb,
Expand Down Expand Up @@ -1237,36 +1239,76 @@ def send_postal_verification_postcard(payload: jobs_pb2.SendPostalVerificationPo

user_name = session.execute(select(User.name).where(User.id == attempt.user_id)).scalar_one()

result = send_postcard(
job_id = send_postcard(
recipient_name=user_name,
address_line_1=attempt.address_line_1,
address_line_2=attempt.address_line_2,
city=attempt.city,
state=attempt.state,
postal_code=attempt.postal_code,
country=attempt.country,
country=attempt.country_code,
verification_code=not_none(attempt.verification_code),
qr_code_url=urls.postal_verification_link(code=not_none(attempt.verification_code)),
)

if result.success:
attempt.status = PostalVerificationStatus.awaiting_verification
attempt.postcard_sent_at = func.now()
attempt.mypostcard_job_id = job_id
attempt.status = PostalVerificationStatus.awaiting_verification
attempt.postcard_sent_at = func.now()

postcards_sent_counter.labels(country_code=attempt.country_code).inc()

context = make_background_user_context(attempt.user_id)
log_event(
context,
session,
"postcard.sent",
{
"attempt_id": attempt.id,
"country": attempt.country_code,
"city": attempt.city,
"mypostcard_job_id": job_id,
},
)

notify(
session,
user_id=attempt.user_id,
topic_action=NotificationTopicAction.postal_verification__postcard_sent,
key="",
data=notification_data_pb2.PostalVerificationPostcardSent(
city=attempt.city,
country=attempt.country,
),
notify(
session,
user_id=attempt.user_id,
topic_action=NotificationTopicAction.postal_verification__postcard_sent,
key="",
data=notification_data_pb2.PostalVerificationPostcardSent(
city=attempt.city,
country=attempt.country_code,
),
)


def check_mypostcard_jobs(payload: empty_pb2.Empty) -> None:
"""
Checks that all MyPostcard jobs from the last week are tied to a postal verification attempt.
"""
with session_scope() as session:
mypostcard_job_ids = set(
get_order_ids(
date_from=(now() - timedelta(days=7)).date(),
date_to=now().date(),
)
)

known_job_ids = set(
session.execute(
select(PostalVerificationAttempt.mypostcard_job_id).where(
PostalVerificationAttempt.mypostcard_job_id.isnot(None),
PostalVerificationAttempt.created >= now() - timedelta(days=14),
)
)
.scalars()
.all()
)

orphaned = mypostcard_job_ids - known_job_ids
if orphaned:
report_message(
f"Found {len(orphaned)} orphaned MyPostcard jobs not tied to any verification attempt: {orphaned}"
)
else:
# Could retry or fail - for now, fail
attempt.status = PostalVerificationStatus.failed
logger.error(f"Postcard send failed: {result.error_message}")


class DatabaseInconsistencyError(Exception):
Expand Down
7 changes: 7 additions & 0 deletions app/backend/src/couchers/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,13 @@ def observe_moderation_queue_resolution_time(
moderation_queue_resolution_time_histogram.labels(trigger.name, action.name, object_type.name).observe(duration_s)


postcards_sent_counter: Counter = Counter(
"couchers_postcards_sent_total",
"Number of postcards sent via MyPostcard",
labelnames=["country_code"],
)


def create_prometheus_server(port: int) -> Any:
"""custom start method to fix problem descrbied in https://github.com/prometheus/client_python/issues/155"""

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""Add mypostcard_job_id and rename country to country_code in postal_verification_attempts

Revision ID: 0142
Revises: 0141
Create Date: 2026-03-18 12:00:00.000000

"""

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision = "0142"
down_revision = "0141"
branch_labels = None
depends_on = None


def upgrade() -> None:
op.add_column("postal_verification_attempts", sa.Column("mypostcard_job_id", sa.Integer(), nullable=True))
op.alter_column("postal_verification_attempts", "country", new_column_name="country_code")


def downgrade() -> None:
op.alter_column("postal_verification_attempts", "country_code", new_column_name="country")
op.drop_column("postal_verification_attempts", "mypostcard_job_id")
5 changes: 4 additions & 1 deletion app/backend/src/couchers/models/postal_verification.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ class PostalVerificationAttempt(Base, kw_only=True):
city: Mapped[str] = mapped_column(String)
state: Mapped[str | None] = mapped_column(String, default=None)
postal_code: Mapped[str | None] = mapped_column(String, default=None)
country: Mapped[str] = mapped_column(String) # ISO 3166-1 alpha-2
country_code: Mapped[str] = mapped_column(String) # ISO 3166-1 alpha-2

# The original address as entered by user (for audit), stored as JSON
original_address_json: Mapped[str | None] = mapped_column(String, default=None)
Expand All @@ -78,6 +78,9 @@ class PostalVerificationAttempt(Base, kw_only=True):
postcard_sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)

# MyPostcard job ID (returned by place_order)
mypostcard_job_id: Mapped[int | None] = mapped_column(Integer, default=None)

# Code entry attempts
code_attempts: Mapped[int] = mapped_column(Integer, server_default=text("0"), init=False)

Expand Down
4 changes: 2 additions & 2 deletions app/backend/src/couchers/postal/address_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ class ValidatedAddress:
city: str
state: str | None
postal_code: str | None
country: str # ISO 3166-1 alpha-2
country_code: str # ISO 3166-1 alpha-2
was_corrected: bool
is_deliverable: bool

Expand Down Expand Up @@ -57,7 +57,7 @@ def validate_address(
city=city.strip(),
state=state.strip() if state else None,
postal_code=postal_code.strip() if postal_code else None,
country=country.strip().upper(),
country_code=country.strip().upper(),
was_corrected=False, # Stub always returns false
is_deliverable=True, # Stub always returns true
)
Loading
Loading