diff --git a/CHANGELOG.md b/CHANGELOG.md index 4163df0..54e7f71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 current streak has already been notified. When the account recovers the flag is cleared and a single recovery notification is sent; the next failure streak will then fire a fresh alert. +## v0.10.2 (2026-05-03) + +### Bug Fixes + +- Add sender_email to UserSmtpConfig to fix SMTP From header (501 bad sender address) + ([`a21e2f7`](https://github.com/christianlouis/InboxConverge/commit/a21e2f7df62ef79271efab172a88d1ead06bfe67)) + ## v0.10.1 (2026-05-03) @@ -44,6 +51,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## v0.10.0 (2026-05-03) +### Bug Fixes + +- **SMTP Fallback sender address**: Add `sender_email` field to `UserSmtpConfig` so the SMTP `From:` header uses a real email address instead of the SMTP authentication token. Providers like Postmark use a UUID API token as the username, which caused a `501 Bad sender address syntax` error. The new field is optional; when left blank the `username` value is used as a fallback (preserving existing behaviour for providers where username == email address). Includes a new Alembic migration (`0003`) and a Sender Email input in the Settings page. + ## v0.9.2 (2026-05-03) diff --git a/backend/alembic/versions/0003_add_smtp_sender_email.py b/backend/alembic/versions/0003_add_smtp_sender_email.py new file mode 100644 index 0000000..384c168 --- /dev/null +++ b/backend/alembic/versions/0003_add_smtp_sender_email.py @@ -0,0 +1,36 @@ +"""Add sender_email column to user_smtp_configs + +Revision ID: 0003 +Revises: 0002 +Create Date: 2026-05-03 + +Adds a ``sender_email`` column to ``user_smtp_configs``. + +SMTP providers such as Postmark use an API token (UUID) as the SMTP username +for authentication, but require a real email address as the ``From:`` header. +This column stores the address that should appear as the sender; when blank, +the existing ``username`` value is used as a fallback so existing rows remain +fully functional. + +Using IF NOT EXISTS makes the migration idempotent against fresh installs +where create_all() already created the column. +""" + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "0003" +down_revision = "0002" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + "ALTER TABLE user_smtp_configs " + "ADD COLUMN IF NOT EXISTS sender_email VARCHAR(255) NOT NULL DEFAULT ''" + ) + + +def downgrade() -> None: + op.execute("ALTER TABLE user_smtp_configs DROP COLUMN IF EXISTS sender_email") diff --git a/backend/app/api/v1/endpoints/users.py b/backend/app/api/v1/endpoints/users.py index 98ccaf8..142838e 100644 --- a/backend/app/api/v1/endpoints/users.py +++ b/backend/app/api/v1/endpoints/users.py @@ -72,6 +72,7 @@ async def get_smtp_config( host=config.host, # type: ignore[arg-type] port=config.port, # type: ignore[arg-type] username=config.username, # type: ignore[arg-type] + sender_email=config.sender_email, # type: ignore[arg-type] use_tls=config.use_tls, # type: ignore[arg-type] has_password=bool(config.encrypted_password), created_at=config.created_at, # type: ignore[arg-type] @@ -95,6 +96,7 @@ async def upsert_smtp_config( config.host = config_in.host # type: ignore[assignment] config.port = config_in.port # type: ignore[assignment] config.username = config_in.username # type: ignore[assignment] + config.sender_email = config_in.sender_email # type: ignore[assignment] config.use_tls = config_in.use_tls # type: ignore[assignment] if config_in.password is not None: config.encrypted_password = encrypt_credential(config_in.password) # type: ignore[assignment] @@ -104,6 +106,7 @@ async def upsert_smtp_config( host=config_in.host, port=config_in.port, username=config_in.username, + sender_email=config_in.sender_email, encrypted_password=( encrypt_credential(config_in.password) if config_in.password else "" ), @@ -120,6 +123,7 @@ async def upsert_smtp_config( host=config.host, # type: ignore[arg-type] port=config.port, # type: ignore[arg-type] username=config.username, # type: ignore[arg-type] + sender_email=config.sender_email, # type: ignore[arg-type] use_tls=config.use_tls, # type: ignore[arg-type] has_password=bool(config.encrypted_password), created_at=config.created_at, # type: ignore[arg-type] @@ -174,7 +178,7 @@ async def test_smtp_config( "plain", "utf-8", ) - msg["From"] = config.username # type: ignore[index] + msg["From"] = config.sender_email or config.username # type: ignore[index] msg["To"] = recipient msg["Date"] = formatdate(localtime=True) msg["Message-ID"] = make_msgid() diff --git a/backend/app/models/database_models.py b/backend/app/models/database_models.py index 016e431..01f2ad2 100644 --- a/backend/app/models/database_models.py +++ b/backend/app/models/database_models.py @@ -484,6 +484,7 @@ class UserSmtpConfig(Base): username = Column(String(255), nullable=False, default="") encrypted_password = Column(Text, nullable=False, default="") use_tls = Column(Boolean, default=True) + sender_email = Column(String(255), nullable=False, default="") created_at = Column( DateTime(timezone=True), diff --git a/backend/app/models/schemas.py b/backend/app/models/schemas.py index cb772f0..109c551 100644 --- a/backend/app/models/schemas.py +++ b/backend/app/models/schemas.py @@ -481,6 +481,7 @@ class UserSmtpConfigBase(BaseModel): host: str = "smtp.gmail.com" port: int = Field(587, gt=0, lt=65536) username: str = "" + sender_email: str = "" use_tls: bool = True diff --git a/backend/app/services/mail_processor.py b/backend/app/services/mail_processor.py index 14acb85..778a11b 100644 --- a/backend/app/services/mail_processor.py +++ b/backend/app/services/mail_processor.py @@ -1444,7 +1444,9 @@ class MailProcessor: # Create forwarding message forward_msg = MIMEMultipart("mixed") - forward_msg["From"] = smtp_config["username"] + forward_msg["From"] = ( + smtp_config.get("sender_email") or smtp_config["username"] + ) forward_msg["To"] = destination forward_msg["Date"] = formatdate(localtime=True) forward_msg["Message-ID"] = make_msgid() diff --git a/backend/app/workers/tasks.py b/backend/app/workers/tasks.py index 6192970..ecdd2ae 100644 --- a/backend/app/workers/tasks.py +++ b/backend/app/workers/tasks.py @@ -210,6 +210,7 @@ async def process_mail_account(account_id: int): "host": user_smtp.host, "port": user_smtp.port, "username": user_smtp.username, + "sender_email": user_smtp.sender_email or "", "password": decrypt_credential(user_smtp.encrypted_password), # type: ignore[arg-type] "use_tls": user_smtp.use_tls, } diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx index db9c4a5..b775472 100644 --- a/frontend/src/app/settings/page.tsx +++ b/frontend/src/app/settings/page.tsx @@ -148,6 +148,7 @@ function SettingsContent() { host: 'smtp.gmail.com', port: 587, username: '', + sender_email: '', password: '', use_tls: true, }); @@ -187,6 +188,7 @@ function SettingsContent() { host: smtpConfig.host, port: smtpConfig.port, username: smtpConfig.username, + sender_email: smtpConfig.sender_email, use_tls: smtpConfig.use_tls, password: '', // never pre-fill password })); @@ -246,7 +248,7 @@ function SettingsContent() { mutationFn: smtpApi.remove, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['smtp-config'] }); - setSmtpForm({ host: 'smtp.gmail.com', port: 587, username: '', password: '', use_tls: true }); + setSmtpForm({ host: 'smtp.gmail.com', port: 587, username: '', sender_email: '', password: '', use_tls: true }); }, }); @@ -302,6 +304,7 @@ function SettingsContent() { host: smtpForm.host, port: smtpForm.port, username: smtpForm.username, + sender_email: smtpForm.sender_email, password: smtpForm.password || undefined, use_tls: smtpForm.use_tls, }); @@ -601,6 +604,21 @@ function SettingsContent() { /> +
+ The From: address used when forwarding mail. Required when your SMTP username is an API token rather than an email address (e.g. Postmark, SendGrid). +
+