feat: Add initial Mobilizon => Discord sync #1

Merged
doomhammer merged 2 commits from add-initial-approach into main 2026-04-20 18:42:05 +00:00
14 changed files with 742 additions and 57 deletions

10
.envrc
View file

@ -1,6 +1,14 @@
#!/usr/bin/env bash #!/usr/bin/env bash
export DIRENV_WARN_TIMEOUT=20s # Install nix_direnv for better caching and handling of nix configuration

Check if actually needed.
Add explanation comment.

Check if actually needed. Add explanation comment.
if ! has nix_direnv_version || ! nix_direnv_version 3.0.5; then
source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.0.5/direnvrc" "sha256-RuwIS+QKFj/T9M2TFXScjBsLR6V3A17YVoEW/Q6AZ1w="
fi
watch_file nix/*.nix
# shell.nix allows using devenv on nix systems that don't have devenv installed
use nix
export DIRENV_WARN_TIMEOUT=30s
eval "$(devenv direnvrc)" eval "$(devenv direnvrc)"

4
.gitignore vendored
View file

@ -4,3 +4,7 @@ devenv.lock
uv.lock uv.lock
__pycache__/ __pycache__/
*.egg-info/ *.egg-info/
.env*
*.pickle
dist/
result

View file

@ -25,7 +25,7 @@ repos:
- id: ruff-format - id: ruff-format
name: Ruff format name: Ruff format
description: Format. description: Format.
entry: ruff format --check --config pyproject.toml entry: ruff format --config pyproject.toml

This was done on purpose - check only in pre-commit hook, force dev to know what formatting has happened.

This was done on purpose - check only in pre-commit hook, force dev to know what formatting has happened.

Then we should have a command that runs all the linters and formatters as we seem to have checks that enforce certain rules but don't have a command to apply those rules.

Then we should have a command that runs all the linters and formatters as we seem to have checks that enforce certain rules but don't have a command to apply those rules.

#19

https://forge.hs3.pl/leming/kronos/issues/19
language: system language: system
types: [python] types: [python]

View file

@ -6,6 +6,12 @@ Share events between services.
Nix is required. Nix is required.
- Add cachix binary cache (this will speed up building):
```bash
cachix use hs3city
```
- Set up environment. - Set up environment.
```bash ```bash
@ -21,7 +27,7 @@ pre-commit install
- Install as editable package. - Install as editable package.
```bash ```bash
pip install -e . uv pip install -e .
``` ```
## Run ## Run

View file

@ -1,7 +1,14 @@
{ pkgs, ... }:
{ {
name = "kronos"; name = "kronos";
languages.python = { cachix = {
pull = [ "hs3city" ];
push = "hs3city";
};
languages = {
python = {
enable = true; enable = true;
venv.enable = true; venv.enable = true;
version = "3.13.0"; version = "3.13.0";
@ -11,4 +18,27 @@
sync.allExtras = true; sync.allExtras = true;
}; };
}; };
};
packages = [
pkgs.uv
];
enterShell = ''
echo
echo "🪝 Installing pre-commit"
echo
pre-commit install
echo
echo " Installing kronos"
echo
uv pip install -e .
echo
echo "🙌 Welcome to kronos' devenv!"
echo 'To run kronos, type `kronos`'
echo
echo
'';
} }

View file

@ -1,11 +0,0 @@
from argparse import ArgumentParser
def _main():
parser = ArgumentParser(prog="kronos", description="Share events between services.")
parser.add_argument("--name")
_args = parser.parse_args()
if __name__ == "__main__":
_main()

146
npins/default.nix Normal file
View file

@ -0,0 +1,146 @@
/*
This file is provided under the MIT licence:
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the Software), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
# Generated by npins. Do not modify; will be overwritten regularly
let
data = builtins.fromJSON (builtins.readFile ./sources.json);
version = data.version;
# https://github.com/NixOS/nixpkgs/blob/0258808f5744ca980b9a1f24fe0b1e6f0fecee9c/lib/lists.nix#L295
range =
first: last: if first > last then [ ] else builtins.genList (n: first + n) (last - first + 1);
# https://github.com/NixOS/nixpkgs/blob/0258808f5744ca980b9a1f24fe0b1e6f0fecee9c/lib/strings.nix#L257
stringToCharacters = s: map (p: builtins.substring p 1 s) (range 0 (builtins.stringLength s - 1));
# https://github.com/NixOS/nixpkgs/blob/0258808f5744ca980b9a1f24fe0b1e6f0fecee9c/lib/strings.nix#L269
stringAsChars = f: s: concatStrings (map f (stringToCharacters s));
concatMapStrings = f: list: concatStrings (map f list);
concatStrings = builtins.concatStringsSep "";
# If the environment variable NPINS_OVERRIDE_${name} is set, then use
# the path directly as opposed to the fetched source.
# (Taken from Niv for compatibility)
mayOverride =
name: path:
let
envVarName = "NPINS_OVERRIDE_${saneName}";
saneName = stringAsChars (c: if (builtins.match "[a-zA-Z0-9]" c) == null then "_" else c) name;
ersatz = builtins.getEnv envVarName;
in
if ersatz == "" then
path
else
# this turns the string into an actual Nix path (for both absolute and
# relative paths)
builtins.trace "Overriding path of \"${name}\" with \"${ersatz}\" due to set \"${envVarName}\"" (
if builtins.substring 0 1 ersatz == "/" then
/. + ersatz
else
/. + builtins.getEnv "PWD" + "/${ersatz}"
);
mkSource =
name: spec:
assert spec ? type;
let
path =
if spec.type == "Git" then
mkGitSource spec
else if spec.type == "GitRelease" then
mkGitSource spec
else if spec.type == "PyPi" then
mkPyPiSource spec
else if spec.type == "Channel" then
mkChannelSource spec
else if spec.type == "Tarball" then
mkTarballSource spec
else
builtins.throw "Unknown source type ${spec.type}";
in
spec // { outPath = mayOverride name path; };
mkGitSource =
{
repository,
revision,
url ? null,
submodules,
hash,
branch ? null,
...
}:
assert repository ? type;
# At the moment, either it is a plain git repository (which has an url), or it is a GitHub/GitLab repository
# In the latter case, there we will always be an url to the tarball
if url != null && !submodules then
builtins.fetchTarball {
inherit url;
sha256 = hash; # FIXME: check nix version & use SRI hashes
}
else
let
url =
if repository.type == "Git" then
repository.url
else if repository.type == "GitHub" then
"https://github.com/${repository.owner}/${repository.repo}.git"
else if repository.type == "GitLab" then
"${repository.server}/${repository.repo_path}.git"
else
throw "Unrecognized repository type ${repository.type}";
urlToName =
url: rev:
let
matched = builtins.match "^.*/([^/]*)(\\.git)?$" url;
short = builtins.substring 0 7 rev;
appendShort = if (builtins.match "[a-f0-9]*" rev) != null then "-${short}" else "";
in
"${if matched == null then "source" else builtins.head matched}${appendShort}";
name = urlToName url revision;
in
builtins.fetchGit {
rev = revision;
inherit name;
# hash = hash;
inherit url submodules;
};
mkPyPiSource =
{ url, hash, ... }:
builtins.fetchurl {
inherit url;
sha256 = hash;
};
mkChannelSource =
{ url, hash, ... }:
builtins.fetchTarball {
inherit url;
sha256 = hash;
};
mkTarballSource =
{
url,
locked_url ? url,
hash,
...
}:
builtins.fetchTarball {
url = locked_url;
sha256 = hash;
};
in
if version == 5 then
builtins.mapAttrs mkSource data.pins
else
throw "Unsupported format version ${toString version} in sources.json. Try running `npins upgrade`"

18
npins/sources.json Normal file
View file

@ -0,0 +1,18 @@
{
"pins": {
"nixpkgs": {
"type": "Git",
"repository": {
"type": "GitHub",
"owner": "nixos",
"repo": "nixpkgs"
},
"branch": "nixos-25.11",
"submodules": false,
"revision": "54170c54449ea4d6725efd30d719c5e505f1c10e",
"url": "https://github.com/nixos/nixpkgs/archive/54170c54449ea4d6725efd30d719c5e505f1c10e.tar.gz",
"hash": "0ayx6v3wwvqpdjkbfnbgyp7rwnsypgn6willidza61x9ilmxkqdp"
}
},
"version": 5
}

View file

@ -4,11 +4,36 @@ version = "0.1.0"
description = "Share events between services." description = "Share events between services."
authors = [{ name = "Hackerspace Trójmiasto" }] authors = [{ name = "Hackerspace Trójmiasto" }]
readme = "README.md" readme = "README.md"
license = { file = "LICENSE" } license-files = ["LICENSE"]

What is the reason for change?

What is the reason for change?

uv made me do it

uv made me do it
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = [] dependencies = [
"beautifulsoup4==4.13.4",
"brevo-python==1.1.2",
"bs4==0.0.2",
"cachetools==6.1.0",
"certifi==2025.4.26",
"charset-normalizer==3.4.2",
"cssselect==1.3.0",
"cssutils==2.11.1",
"discord.py==2.7.1",
"html5lib==1.1",
"idna==3.10",
"Jinja2==3.1.6",
"lxml==6.0.0",
"MarkupSafe==3.0.2",
"more-itertools==10.7.0",
"premailer==3.10.0",
"python-dateutil==2.9.0.post0",
"python-dotenv==1.1.1",
"requests==2.32.4",
"six==1.17.0",
"soupsieve==2.7",
"typing_extensions==4.14.0",
"urllib3==2.5.0",
"webencodings==0.5.1",
]
[dependency-groups] [dependency-groups]
dev = ["pre-commit", "pre-commit-hooks", "pytest", "ruff"] dev = ["pre-commit", "pre-commit-hooks", "pytest", "ruff"]
@ -47,7 +72,7 @@ exclude = [
] ]
line-length = 100 line-length = 100
indent-width = 4 indent-width = 2

What is the reason for change?

What is the reason for change?
  1. I prefer smaller files
  2. For some reason ruff seemed to like the line length when formatting but not when linting. Using 2 spaces for indent made it shut its yap. I know, ugly. And disgusting. But I spent too much time looking for a solution...
1. I prefer smaller files 2. For some reason ruff seemed to like the line length when formatting but not when linting. Using 2 spaces for indent made it shut its yap. I know, ugly. And disgusting. But I spent too much time looking for a solution...
target-version = "py313" target-version = "py313"
[tool.ruff.lint] [tool.ruff.lint]

17
shell.nix Normal file
View file

@ -0,0 +1,17 @@
# Use npins to pin nixpkgs dependencies

Not an issue, but let me know what happens here.

Not an issue, but let me know what happens here.

I'll add the comments to the file

I'll add the comments to the file
# This makes it easier to cache build results
{
sources ? import ./npins,
system ? builtins.currentSystem,
pkgs ? import sources.nixpkgs {
inherit system;
config = { };
overlays = [ ];
},
}:
# Create a nix shell with devenv (so `use devenv` can work in direnv/.envrc)
pkgs.mkShell {
buildInputs = with pkgs; [
devenv
];
}

86
src/kronos/discordbot.py Normal file
View file

@ -0,0 +1,86 @@
import pickle
import os.path
import urllib.request
import discord
class KronosBot(discord.Client):
def attach_events(self, events):
print(f"Adding {len(events)} events")
self.events = events
async def on_ready(self):
event_mapping_file = "event_mapping.pickle"
event_mapping = dict()
try:
if os.path.isfile(event_mapping_file):
with open(event_mapping_file, "rb") as f:
event_mapping = pickle.load(f)
except Exception:
print("There are problems opening the pickle jar")
try:
print(f"Logged on as {self.user}!")
guilds_total = len(self.guilds)
print(f"I work for {guilds_total} guilds")
guilds_processed = 0
for guild in self.guilds:
guild_id = str(guild)
print(f"Processing {guild.name} as {guild.me.name}")
guilds_processed += 1
for event in self.events:
description = event["link"] + "\n\n" + event["description"]
picture_url = "https://hs3.pl/images/zasoby/null.jpg"
if event["picture_url"] is not None:
picture_url = event["picture_url"]
pic = urllib.request.urlopen(picture_url).read()
print(f"Processing event {event}")
if event["link"] in event_mapping and guild_id in event_mapping[event["link"]]:
print("Updating the event, this one already processed")
discord_event = guild.get_scheduled_event(event_mapping[event["link"]][guild_id])
if discord_event is not None:
await discord_event.edit(
name=event["title"],
description=description,
start_time=event["start_time"],
end_time=event["end_time"],
location=event["location"],
image=pic,
)
else:
discord_event = await guild.create_scheduled_event(
name=event["title"],
description=description,
start_time=event["start_time"],
end_time=event["end_time"],
location=event["location"],
image=pic,
entity_type=discord.EntityType.external,
privacy_level=discord.PrivacyLevel.guild_only,
)
if event["link"] not in event_mapping:
event_mapping[event["link"]] = dict()
event_mapping[event["link"]][guild_id] = discord_event.id
print(f"Mobilizon event {event['link']} mapped to Discord event {discord_event.id}")
print(f"Dumping event mapping to a pickle: {event_mapping}")
with open(event_mapping_file, "wb") as f:
pickle.dump(event_mapping, f)
except Exception:
print("There are problems processing the events, mate")
print("So long, and thanks for all the fish!")
await self.close()
async def on_message(self, message):
print(f"Message from {message.author}: {message.content}")
intents = discord.Intents.default()
intents.message_content = False
client = KronosBot(intents=intents)

253
src/kronos/main.py Executable file
View file

@ -0,0 +1,253 @@
import datetime
import os
import sys
from zoneinfo import ZoneInfo
from argparse import ArgumentParser
import requests
from bs4 import BeautifulSoup
from dotenv import load_dotenv
from jinja2 import Environment, FileSystemLoader, select_autoescape
from premailer import transform
from .discordbot import KronosBot, intents
MOBILIZON_API_URL = "https://events.hs3.pl/api"
QUERY_LIMIT = 100
TIME_WINDOW_DAYS = 365
TRUNCATE_AFTER_CHARACTERS = 300
TEMPLATE_FILENAME = "templates/newsletter_template.html"
OUTPUT_FILENAME = "newsletter_events.html"
def sanitize_html(raw_html: str) -> str:
wrapper = f"<div>{raw_html}</div>"
soup = BeautifulSoup(wrapper, "html5lib")
div = soup.find("div")
cleaned = "".join(str(child) for child in div.contents)
return cleaned
def get_time_window(days: int = 365):
now_utc = datetime.datetime.now(datetime.timezone.utc)
ends_utc = now_utc + datetime.timedelta(days=days)
begins_iso = now_utc.replace(microsecond=0).isoformat().replace("+00:00", "Z")
ends_iso = ends_utc.replace(microsecond=0).isoformat().replace("+00:00", "Z")
return begins_iso, ends_iso
def build_graphql_query():
return """
query SearchEventsInWindow($beginsOn: DateTime, $endsOn: DateTime, $limit: Int) {
searchEvents(beginsOn: $beginsOn, endsOn: $endsOn, limit: $limit) {
total
elements {
__typename
... on Event {
id
title
description
beginsOn
picture {
url
}
url
physicalAddress {
description
locality
}
}
}
}
}
"""
def fetch_events(begins_on: str, ends_on: str, limit: int = QUERY_LIMIT):
query = build_graphql_query()
variables = {"beginsOn": begins_on, "endsOn": ends_on, "limit": limit}
payload = {"query": query, "variables": variables}
headers = {"Content-Type": "application/json"}
resp = requests.post(MOBILIZON_API_URL, json=payload, headers=headers)
resp.raise_for_status()
data = resp.json()
if "errors" in data:
raise RuntimeError(f"GraphQL error: {data['errors']}")
events = []
for elem in data["data"]["searchEvents"]["elements"]:
if elem.get("__typename") == "Event":
begins_iso = elem.get("beginsOn")
if begins_iso:
dt = datetime.datetime.fromisoformat(begins_iso.replace("Z", "+00:00"))
dt_begins = datetime.datetime.fromisoformat(begins_on.replace("Z", "+00:00"))
dt_ends = datetime.datetime.fromisoformat(ends_on.replace("Z", "+00:00"))
if dt_begins <= dt < dt_ends:
events.append(elem)
return events
def prepare_events_for_template(raw_events: list) -> list:
jours_semaine = [
doomhammer marked this conversation as resolved

What's with the french?

What's with the french?

It was stolen from the French: https://github.com/cleming/newsletter-kalepin

It was stolen from the French: https://github.com/cleming/newsletter-kalepin
"poniedziałek",
"wtorek",
"środa",
"czwartek",
"piątek",
"sobota",
"niedziela",
]
mois_annee = [
"",
"stycznia",
"lutego",
"marca",
"kwietnia",
"maja",
"czerwca",
"lipca",
"sierpnia",
"września",
"października",
"listopada",
"grudnia",
]
prepared = []
for ev in raw_events:
title = ev.get("title", "Untitled")
raw_desc = ev.get("description") or ""
cleaned_html = sanitize_html(raw_desc)
text_only = BeautifulSoup(cleaned_html, "html.parser").get_text()
if len(text_only) > TRUNCATE_AFTER_CHARACTERS:
truncated = text_only[:300].rstrip() + ""
else:
truncated = text_only
begins_iso = ev.get("beginsOn")
picture = ev.get("picture")
picture_url = picture.get("url") if picture else None
link = ev.get("url") or ""
if picture_url and "?" in picture_url:
picture_url = picture_url.split("?", 1)[0]
phys = ev.get("physicalAddress")
if phys:
part1 = phys.get("description") or ""
part2 = phys.get("locality") or ""
location = ", ".join(x for x in (part1, part2) if x.strip())
else:
location = ""
dt_utc = datetime.datetime.fromisoformat(begins_iso.replace("Z", "+00:00"))
dt_paris = dt_utc.astimezone(ZoneInfo("Europe/Paris"))
jour_nom = jours_semaine[dt_paris.weekday()]
jour_num = dt_paris.day
mois_nom = mois_annee[dt_paris.month]
annee = dt_paris.year
heure = dt_paris.hour
minute = dt_paris.minute
minute_str = f"{minute:02d}"
full_date = f"{jour_nom}, {jour_num} {mois_nom} {annee} @ {heure}:{minute_str}"
prepared.append(
{
"title": title,
"description": truncated,
"full_date": full_date,
"picture_url": picture_url,
"location": location,
"link": link,
"start_time": dt_utc,
"end_time": dt_utc + datetime.timedelta(hours=1),
}
)
return prepared
def render_newsletter(events: list, template_dir: str, template_name: str) -> str:
env = Environment(
loader=FileSystemLoader(searchpath=template_dir),
autoescape=select_autoescape(["html", "xml"]),
)
template = env.get_template(template_name)
now_paris = datetime.datetime.now(ZoneInfo("Europe/Paris")).strftime("%Y-%m-%d %H:%M")
return template.render(events=events, date_now=now_paris)
def inline_css(input_html_path, output_html_path):
with open(input_html_path, "r", encoding="utf-8") as f:
html = f.read()
inlined_html = transform(html)
with open(output_html_path, "w", encoding="utf-8") as f:
f.write(inlined_html)
def _main():
load_dotenv()
parser = ArgumentParser(prog="kronos", description="Share events between services.")
parser.add_argument("--name")
_args = parser.parse_args()
try:
begins_on, ends_on = get_time_window(days=TIME_WINDOW_DAYS)
print(
f"Fetching events between {begins_on} and {ends_on}...",
file=sys.stderr,
)
raw_events = list(
filter(
lambda x: "https://events.hs3.pl/" in x.get("url", ""),
fetch_events(begins_on, ends_on),
)
)
if not raw_events:
print("No events found in the requested period.", file=sys.stderr)
sys.exit(0)
for ev in raw_events:
print(f"{ev.get('title', 'Untitled')}{ev.get('beginsOn', 'Unknown date')}")
raw_events.sort(key=lambda ev: ev.get("beginsOn", ""))
events = prepare_events_for_template(raw_events)
os.makedirs("dist", exist_ok=True)
output_path = os.path.join(os.getcwd(), "dist", OUTPUT_FILENAME)
html_output = render_newsletter(
events=events, template_dir=os.getcwd(), template_name=TEMPLATE_FILENAME
)
with open(output_path, "w", encoding="utf-8") as f:
f.write(html_output)
print(f"File '{output_path}' generated successfully.", file=sys.stderr)
inlined_output_path = os.path.join(os.getcwd(), "dist", "newsletter_events_inlined.html")
inline_css(output_path, inlined_output_path)
print(
f"File '{inlined_output_path}' (inline CSS) generated.",
file=sys.stderr,
)
discord_token = os.getenv("DISCORD_TOKEN")
client = KronosBot(intents=intents)
client.attach_events(events)
client.run(discord_token)
except Exception as exc:
print(f"Error: {exc}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
_main()

View file

@ -0,0 +1,103 @@
<!DOCTYPE html>
<html lang="plr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Najbliższe wydarzenia w Hackerspace Trójmiasto (HS3)</title>
<style>
/* Dark mode */
@media (prefers-color-scheme: dark) {
.dark-mode-button {
background: #ffffff !important;
color: #000000 !important;
border: 1px solid #cccccc !important;
}
}
@media only screen and (max-width: 600px) {
.event-table {
display: block !important;
width: 100% !important;
}
.event-table tbody,
.event-table tr,
.event-table td {
display: block !important;
width: 100% !important;
box-sizing: border-box !important;
}
.event-image {
text-align: center !important;
padding: 20px 20px 10px 20px !important;
}
.event-content {
padding: 10px 10px 20px 10px !important;
width: 100% !important;
}
.event-image img {
width: 100% !important;
height: auto !important;
max-width: 300px !important;
}
/* Simplification mobile */
.event-content * {
max-width: none !important;
width: auto !important;
display: inline-block !important;
white-space: normal !important;
}
.event-content h2 {
display: block !important;
width: 100% !important;
}
.event-content a {
display: inline-block !important;
width: auto !important;
max-width: 200px !important;
}
.event-content div[style*="background:#ff7105"] {
white-space: nowrap !important;
font-size: 12px !important;
padding: 4px 8px !important;
}
}
</style>
</head>
<body style="background-color:#f0f2f5; font-family:Helvetica,Arial,sans-serif; color:#333; padding:20px;">
<div style="max-width:800px; margin:0 auto; background:#fff; border-radius:8px; overflow:hidden; box-shadow:0 4px 16px #eee;">
<div style="text-align:center; padding:40px 20px 20px 20px; background:#4B64F2;">
<img src="https://img.notionusercontent.com/s3/prod-files-secure%2F00deef52-50e0-4f54-b2ee-5c2fbc2c4c6d%2F2ad5c423-a798-4a76-9a07-9be972f119cb%2FHS_LOGO_COLOR_BLACK.png/size/w=1440?exp=1773155386&sig=QdEp5ee-WNVt8umc9b3zwnO4lrUpbwRKYym5aG0zyL4&id=89cbb915-e2d5-4d18-85d7-2c8cf5bae1b5&table=block" alt="Hackerspace Trójmiasto" style="width:230px; display:block; margin:0 auto 24px auto;">
<h1 style="color:#fff; font-size:24px; font-weight:500; margin:0;">NAJBLIŻSZE WYDARZENIA W HACKERSPACE TRÓJMIASTO (HS3)</h1>
</div>
<div style="padding:32px 10px 10px 10px; overflow-x:hidden;">
{% for event in events %}
<table class="event-table" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin-bottom:32px; background:#fff; border-radius:8px; border:1px solid #eee; table-layout:fixed;">
<tr>
{% if event.picture_url %}
<td class="event-image" width="220" valign="top" style="padding:20px;">
<img src="{{ event.picture_url }}" alt="Photo de l'événement" style="display:block; max-width:200px; height:auto;">
</td>
{% endif %}
<td class="event-content" valign="top" style="padding:20px; overflow:hidden;">
<h2 style="font-size:20px; color:#2a2a2a; margin:0 0 8px 0; line-height:1.2; max-width:100%; white-space:normal;">{{ event.title }}</h2>
<div style="background:#ff7105; color:#fff; padding:6px 12px; border-radius:20px; font-size:14px; margin-bottom:12px; margin-top:4px; display:inline-block; font-weight:500; white-space:nowrap;">
{{ event.full_date }}
</div>
{% if event.location %}
<div style="font-size:15px; color:#000; font-weight:500; margin-bottom:6px; max-width:100%; white-space:normal;">Gdzie : {{ event.location }}</div>
{% endif %}
<div style="font-size:15px; color:#444; margin-bottom:12px; max-width:100%; white-space:normal;">{{ event.description }}</div>
{% if event.link %}
<a href="{{ event.link }}" class="dark-mode-button" style="display:inline-block; background:#4B64F2; color:#fff; text-decoration:none; padding:10px 20px; border-radius:4px; font-weight:500;">Sprawdź na HS3 Events</a>
{% endif %}
</td>
</tr>
</table>
{% endfor %}
</div>
<div style="padding:20px; text-align:center; font-size:14px; color:#777; background:#fafbfd;">
<p>Otrzymujesz ten newsletter ponieważ jesteś super cool 1337 osobą subskrybującą info HS3.</p>
</div>
</div>
</body>
</html>